From 65da785a9d26d223f933736dd86068d659110323 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 9 Jun 2026 11:43:58 +0300 Subject: [PATCH 1/7] feat: add shared ai workspace assistant service --- README.md | 4 + infra/.env.example | 7 + infra/docker-compose.dev.yml | 43 + infra/synology/.env.synology.example | 6 + infra/synology/apply-current-runtime.sh | 10 +- infra/synology/deploy-current.sh | 12 +- .../synology/docker-compose.platform-http.yml | 48 + infra/synology/verify-current-runtime.sh | 4 + services/ai-workspace-assistant/Dockerfile | 13 + services/ai-workspace-assistant/README.md | 64 ++ .../ai-workspace-assistant/package-lock.json | 990 ++++++++++++++++++ services/ai-workspace-assistant/package.json | 14 + .../ai-workspace-assistant/src/server.mjs | 904 ++++++++++++++++ 13 files changed, 2116 insertions(+), 3 deletions(-) create mode 100644 services/ai-workspace-assistant/Dockerfile create mode 100644 services/ai-workspace-assistant/README.md create mode 100644 services/ai-workspace-assistant/package-lock.json create mode 100644 services/ai-workspace-assistant/package.json create mode 100644 services/ai-workspace-assistant/src/server.mjs diff --git a/README.md b/README.md index b41a4ea..ad040e2 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ Git repo: - `infra/README.md` - `packages/auth-sdk/README.md` - `services/notification-core/README.md` +- `services/ai-workspace-assistant/README.md` +- `services/ai-workspace-hub/README.md` - `tasks/CODEX_PLATFORM_AUTH_TASK.md` ## Базовое правило @@ -39,3 +41,5 @@ Git repo: Plane не переносится внутрь Launcher. Launcher не становится владельцем таблиц Plane. Authentik становится единым Identity Provider, а приложения сохраняют собственные доменные БД и роли. Notification Core живёт в `services/notification-core` как отдельный платформенный сервис с собственным Postgres. Он не использует Authentik DB: Authentik отвечает за identity/session, Notification Core отвечает за events/deliveries/read-state. + +AI Workspace Assistant живёт в `services/ai-workspace-assistant` как общий платформенный слой для пользовательских Codex executors, selected executor, shared conversations, surfaces и tool packs. AI Workspace Hub остаётся отдельным тонким транспортом для remote Codex workers и не хранит смысловое состояние ассистента. diff --git a/infra/.env.example b/infra/.env.example index 99a0757..1f799b8 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -52,3 +52,10 @@ NOTIFICATION_PG_DB=nodedc_notifications NOTIFICATION_PG_USER=nodedc_notifications NOTIFICATION_PG_PASS=change-me-generate-with-infra-scripts-init-dev-env NODEDC_NOTIFICATION_CORE_URL=http://notification-core:5185 + +# AI Workspace Assistant shared registry/conversations +AI_WORKSPACE_PG_DB=nodedc_ai_workspace +AI_WORKSPACE_PG_USER=nodedc_ai_workspace +AI_WORKSPACE_PG_PASS=change-me-generate-with-infra-scripts-init-dev-env +AI_WORKSPACE_ASSISTANT_TOKEN=change-me-generate-with-infra-scripts-init-dev-env +NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082 diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index 0461889..7dd8e18 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -20,6 +20,8 @@ services: condition: service_started ai-workspace-hub: condition: service_started + ai-workspace-assistant: + condition: service_started extra_hosts: - "host.docker.internal:host-gateway" @@ -128,6 +130,46 @@ services: notification-postgres: condition: service_healthy + 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} + POSTGRES_USER: ${AI_WORKSPACE_PG_USER:-nodedc_ai_workspace} + healthcheck: + test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + volumes: + - ai-workspace-database:/var/lib/postgresql/data + + ai-workspace-assistant: + image: nodedc/ai-workspace-assistant:local + build: + context: ../services/ai-workspace-assistant + restart: unless-stopped + env_file: + - path: .env + required: false + environment: + NODE_ENV: production + PORT: 18082 + DATABASE_URL: postgres://${AI_WORKSPACE_PG_USER:-nodedc_ai_workspace}:${AI_WORKSPACE_PG_PASS:-nodedc_ai_workspace}@ai-workspace-postgres:5432/${AI_WORKSPACE_PG_DB:-nodedc_ai_workspace} + AI_WORKSPACE_ASSISTANT_TOKEN: ${AI_WORKSPACE_ASSISTANT_TOKEN:-dev-ai-workspace-assistant-token} + expose: + - "18082" + ports: + - "${AI_WORKSPACE_ASSISTANT_HOST_BIND:-127.0.0.1:18082}:18082" + depends_on: + ai-workspace-postgres: + condition: service_healthy + ai-workspace-hub: image: nodedc/ai-workspace-hub:local build: @@ -151,5 +193,6 @@ volumes: authentik-data: authentik-certs: notification-database: + ai-workspace-database: caddy-data: caddy-config: diff --git a/infra/synology/.env.synology.example b/infra/synology/.env.synology.example index 177d931..2a0eecc 100644 --- a/infra/synology/.env.synology.example +++ b/infra/synology/.env.synology.example @@ -57,5 +57,11 @@ NOTIFICATION_PG_USER=nodedc_notifications NOTIFICATION_PG_PASS=replace-with-random-synology-secret NODEDC_NOTIFICATION_CORE_URL=http://notification-core:5185 +AI_WORKSPACE_PG_DB=nodedc_ai_workspace +AI_WORKSPACE_PG_USER=nodedc_ai_workspace +AI_WORKSPACE_PG_PASS=replace-with-random-synology-secret +AI_WORKSPACE_ASSISTANT_TOKEN=replace-with-random-synology-secret +NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082 + AI_WORKSPACE_HUB_TOKEN=replace-with-random-synology-secret AI_WORKSPACE_HUB_HOST_BIND=0.0.0.0:18081 diff --git a/infra/synology/apply-current-runtime.sh b/infra/synology/apply-current-runtime.sh index e1d7274..ef025eb 100755 --- a/infra/synology/apply-current-runtime.sh +++ b/infra/synology/apply-current-runtime.sh @@ -30,12 +30,16 @@ echo "== ai workspace hub image build ==" cd "${PLATFORM_DIR}/ai-workspace-hub" "${DOCKER_BIN}" build --no-cache -t nodedc/ai-workspace-hub:local . +echo "== ai workspace assistant image build ==" +cd "${PLATFORM_DIR}/ai-workspace-assistant" +"${DOCKER_BIN}" build --no-cache -t nodedc/ai-workspace-assistant:local . + echo "== platform services recreate ==" cd "${PLATFORM_DIR}" "${DOCKER_BIN}" compose \ --env-file "${ENV_FILE}" \ -f "${COMPOSE_FILE}" \ - up -d --force-recreate reverse-proxy authentik-server authentik-worker notification-postgres notification-core ai-workspace-hub launcher + up -d --force-recreate reverse-proxy authentik-server authentik-worker notification-postgres notification-core ai-workspace-postgres ai-workspace-assistant ai-workspace-hub launcher echo "== notification core health check ==" "${DOCKER_BIN}" exec nodedc-platform-notification-core-1 sh -lc \ @@ -45,6 +49,10 @@ echo "== ai workspace hub health check ==" "${DOCKER_BIN}" exec nodedc-platform-ai-workspace-hub-1 sh -lc \ 'node -e '"'"'fetch("http://127.0.0.1:18081/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"'' +echo "== ai workspace assistant health check ==" +"${DOCKER_BIN}" exec nodedc-platform-ai-workspace-assistant-1 sh -lc \ + 'node -e '"'"'fetch("http://127.0.0.1:18082/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"'' + echo "== clear authentik global brand css ==" DOCKER_BIN="${DOCKER_BIN}" bash "${PLATFORM_DIR}/clear-authentik-brand-css.sh" diff --git a/infra/synology/deploy-current.sh b/infra/synology/deploy-current.sh index 9c944de..a422902 100755 --- a/infra/synology/deploy-current.sh +++ b/infra/synology/deploy-current.sh @@ -51,6 +51,14 @@ rsync -av --delete \ "${PLATFORM_REPO}/services/ai-workspace-hub/" \ "${NAS_ROOT}/platform/ai-workspace-hub/" +mkdir -p "${NAS_ROOT}/platform/ai-workspace-assistant" +rsync -av --delete \ + --exclude='node_modules/' \ + --exclude='.env' \ + --exclude='.env.*' \ + "${PLATFORM_REPO}/services/ai-workspace-assistant/" \ + "${NAS_ROOT}/platform/ai-workspace-assistant/" + if [[ "${SYNC_AUTHENTIK_TEMPLATES}" == "1" ]]; then mkdir -p "${NAS_ROOT}/authentik/custom-templates" rsync -av --delete \ @@ -201,14 +209,14 @@ cd /volume1/docker/nodedc-platform/platform sudo /usr/local/bin/docker compose \ --env-file /volume1/docker/nodedc-platform/platform/.env.synology \ -f /volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml \ - up -d --build --force-recreate --no-deps ai-workspace-hub notification-core launcher + up -d --build --force-recreate --no-deps ai-workspace-hub ai-workspace-assistant notification-core launcher Optional Platform/Auth infra apply, only after deliberate compose/proxy/Auth templates changes: sudo /usr/local/bin/docker compose \ --env-file /volume1/docker/nodedc-platform/platform/.env.synology \ -f /volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml \ - up -d --build --force-recreate --no-deps reverse-proxy authentik-server authentik-worker ai-workspace-hub notification-core launcher + up -d --build --force-recreate --no-deps reverse-proxy authentik-server authentik-worker ai-workspace-hub ai-workspace-assistant notification-core launcher After Authentik template rollout, clear global Brand custom CSS from the live DB. NODE.DC login CSS must be template-scoped, not stored in Brand.branding_custom_css: diff --git a/infra/synology/docker-compose.platform-http.yml b/infra/synology/docker-compose.platform-http.yml index dc78da6..7d38544 100644 --- a/infra/synology/docker-compose.platform-http.yml +++ b/infra/synology/docker-compose.platform-http.yml @@ -21,6 +21,8 @@ services: condition: service_started ai-workspace-hub: condition: service_started + ai-workspace-assistant: + condition: service_started extra_hosts: - "id.nodedc.ru:host-gateway" - "hub.nodedc.ru:host-gateway" @@ -43,6 +45,7 @@ services: NODEDC_AUTHENTIK_BASE_URL: http://nodedc-platform-authentik-server:9000 AUTHENTIK_BASE_URL: http://nodedc-platform-authentik-server:9000 NODEDC_NOTIFICATION_CORE_URL: http://notification-core:5185 + NODEDC_AI_WORKSPACE_ASSISTANT_URL: http://ai-workspace-assistant:18082 expose: - "5173" volumes: @@ -102,6 +105,50 @@ services: - identity - engine + ai-workspace-postgres: + image: postgres:16-alpine + restart: unless-stopped + env_file: + - ${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology} + environment: + POSTGRES_DB: ${AI_WORKSPACE_PG_DB:-nodedc_ai_workspace} + POSTGRES_PASSWORD: ${AI_WORKSPACE_PG_PASS:?ai workspace database password required} + POSTGRES_USER: ${AI_WORKSPACE_PG_USER:-nodedc_ai_workspace} + healthcheck: + test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + volumes: + - ai-workspace-database:/var/lib/postgresql/data + networks: + - identity + + ai-workspace-assistant: + image: nodedc/ai-workspace-assistant:local + build: + context: ./ai-workspace-assistant + restart: unless-stopped + env_file: + - ${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology} + environment: + NODE_ENV: production + PORT: 18082 + DATABASE_URL: postgres://${AI_WORKSPACE_PG_USER:-nodedc_ai_workspace}:${AI_WORKSPACE_PG_PASS:?ai workspace database password required}@ai-workspace-postgres:5432/${AI_WORKSPACE_PG_DB:-nodedc_ai_workspace} + AI_WORKSPACE_ASSISTANT_TOKEN: ${AI_WORKSPACE_ASSISTANT_TOKEN:?ai workspace assistant token required} + NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-} + expose: + - "18082" + ports: + - "${AI_WORKSPACE_ASSISTANT_HOST_BIND:-127.0.0.1:18082}:18082" + depends_on: + ai-workspace-postgres: + condition: service_healthy + networks: + - identity + - engine + ai-workspace-hub: image: nodedc/ai-workspace-hub:local build: @@ -209,5 +256,6 @@ volumes: authentik-data: authentik-certs: notification-database: + ai-workspace-database: caddy-data: caddy-config: diff --git a/infra/synology/verify-current-runtime.sh b/infra/synology/verify-current-runtime.sh index d851800..9541a67 100755 --- a/infra/synology/verify-current-runtime.sh +++ b/infra/synology/verify-current-runtime.sh @@ -59,6 +59,10 @@ echo "== ai workspace hub health check ==" "${DOCKER_BIN}" exec nodedc-platform-ai-workspace-hub-1 sh -lc \ 'node -e '"'"'fetch("http://127.0.0.1:18081/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"'' +echo "== ai workspace assistant health check ==" +"${DOCKER_BIN}" exec nodedc-platform-ai-workspace-assistant-1 sh -lc \ + 'node -e '"'"'fetch("http://127.0.0.1:18082/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"'' + echo "== auth flow check ==" auth_flow="$(fetch_with_retry https://id.nodedc.ru/if/flow/default-authentication-flow/)" printf '%s' "$auth_flow" \ diff --git a/services/ai-workspace-assistant/Dockerfile b/services/ai-workspace-assistant/Dockerfile new file mode 100644 index 0000000..6879275 --- /dev/null +++ b/services/ai-workspace-assistant/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY src ./src + +ENV NODE_ENV=production +EXPOSE 18082 + +CMD ["npm", "start"] diff --git a/services/ai-workspace-assistant/README.md b/services/ai-workspace-assistant/README.md new file mode 100644 index 0000000..030971c --- /dev/null +++ b/services/ai-workspace-assistant/README.md @@ -0,0 +1,64 @@ +# NODE.DC AI Workspace Assistant + +Platform-owned shared state service for the cross-surface AI Workspace assistant. + +It owns: + +- user AI Workspace executors/devices and selected executor; +- assistant conversation/thread metadata; +- surface context and enabled tool packs; +- linked artifacts between Engine, Ops and future platform surfaces. + +It does not replace the AI Workspace Hub. The Hub remains the thin transport/rendezvous layer for remote Codex workers. + +It does not replace the legacy Ops MCP config flow: + +```text +external Codex client -> ~/.codex/config.toml -> nodedc-ops-agent MCP -> Ops Agent Gateway +``` + +That flow remains a separate product path. + +## API + +All endpoints require the shared internal token. App BFFs pass the resolved platform subject through: + +```text +X-NODEDC-User-Id +X-NODEDC-User-Email +``` + +Executor registry: + +```text +GET /api/ai-workspace/assistant/v1/executors +POST /api/ai-workspace/assistant/v1/executors +PATCH /api/ai-workspace/assistant/v1/executors/:executorId +DELETE /api/ai-workspace/assistant/v1/executors/:executorId +POST /api/ai-workspace/assistant/v1/executors/:executorId/select +``` + +Conversations: + +```text +GET /api/ai-workspace/assistant/v1/threads +POST /api/ai-workspace/assistant/v1/threads +GET /api/ai-workspace/assistant/v1/threads/:threadId +PATCH /api/ai-workspace/assistant/v1/threads/:threadId +POST /api/ai-workspace/assistant/v1/threads/:threadId/messages +GET /api/ai-workspace/assistant/v1/threads/:threadId/messages +``` + +Supported initial surfaces: + +- `engine` +- `ops` +- `global` + +Supported initial tool packs: + +- `engine` +- `ops` +- `ndc-agent-core` +- `deploy` +- `docs` diff --git a/services/ai-workspace-assistant/package-lock.json b/services/ai-workspace-assistant/package-lock.json new file mode 100644 index 0000000..19f70be --- /dev/null +++ b/services/ai-workspace-assistant/package-lock.json @@ -0,0 +1,990 @@ +{ + "name": "@nodedc/ai-workspace-assistant", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@nodedc/ai-workspace-assistant", + "version": "0.1.0", + "dependencies": { + "express": "^5.2.1", + "pg": "^8.18.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/services/ai-workspace-assistant/package.json b/services/ai-workspace-assistant/package.json new file mode 100644 index 0000000..848e4cd --- /dev/null +++ b/services/ai-workspace-assistant/package.json @@ -0,0 +1,14 @@ +{ + "name": "@nodedc/ai-workspace-assistant", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/server.mjs", + "dev": "node --watch src/server.mjs" + }, + "dependencies": { + "express": "^5.2.1", + "pg": "^8.18.0" + } +} diff --git a/services/ai-workspace-assistant/src/server.mjs b/services/ai-workspace-assistant/src/server.mjs new file mode 100644 index 0000000..01a1e31 --- /dev/null +++ b/services/ai-workspace-assistant/src/server.mjs @@ -0,0 +1,904 @@ +import express from "express"; +import { randomUUID, timingSafeEqual } from "node:crypto"; +import { createServer } from "node:http"; +import { Pool } from "pg"; + +const SUPPORTED_SURFACES = new Set(["engine", "ops", "global"]); +const SUPPORTED_EXECUTOR_TYPES = new Set(["codex-remote", "ndc-agent-core"]); +const SUPPORTED_CONNECTION_MODES = new Set(["hub", "direct"]); +const SUPPORTED_EXECUTOR_STATUSES = new Set(["unknown", "online", "offline", "checking", "error"]); +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 config = readConfig(); +const pool = new Pool({ connectionString: config.databaseUrl, max: config.databasePoolSize }); +const app = express(); +const httpServer = createServer(app); + +app.disable("x-powered-by"); +app.use(express.json({ limit: "2mb" })); + +app.get("/healthz", asyncRoute(async (_req, res) => { + await pool.query("select 1"); + res.json({ + ok: true, + service: "nodedc-ai-workspace-assistant", + database: "ready", + internalApiConfigured: Boolean(config.internalAccessToken), + }); +})); + +app.get("/api/ai-workspace/assistant/v1/executors", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const [executors, settings] = await Promise.all([ + listExecutors(owner), + getOwnerSettings(owner), + ]); + res.json({ ok: true, owner: publicOwner(owner), selectedExecutorId: settings.selectedExecutorId, executors }); +})); + +app.post("/api/ai-workspace/assistant/v1/executors", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const command = sanitizeExecutorCommand(req.body, { partial: false }); + const executor = await createExecutor(owner, command); + if (req.body?.select === true) { + await selectExecutor(owner, executor.id); + } + const settings = await getOwnerSettings(owner); + res.status(201).json({ ok: true, owner: publicOwner(owner), selectedExecutorId: settings.selectedExecutorId, executor }); +})); + +app.patch("/api/ai-workspace/assistant/v1/executors/:executorId", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const executorId = sanitizeUuid(req.params.executorId, "executorId"); + const command = sanitizeExecutorCommand(req.body, { partial: true }); + const executor = await updateExecutor(owner, executorId, command); + if (!executor) { + res.status(404).json({ ok: false, error: "ai_workspace_executor_not_found" }); + return; + } + res.json({ ok: true, owner: publicOwner(owner), executor }); +})); + +app.delete("/api/ai-workspace/assistant/v1/executors/:executorId", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const executorId = sanitizeUuid(req.params.executorId, "executorId"); + const deleted = await deleteExecutor(owner, executorId); + if (!deleted) { + res.status(404).json({ ok: false, error: "ai_workspace_executor_not_found" }); + return; + } + res.json({ ok: true, owner: publicOwner(owner), deleted: true }); +})); + +app.post("/api/ai-workspace/assistant/v1/executors/:executorId/select", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const executorId = sanitizeUuid(req.params.executorId, "executorId"); + const executor = await selectExecutor(owner, executorId); + if (!executor) { + res.status(404).json({ ok: false, error: "ai_workspace_executor_not_found" }); + return; + } + res.json({ ok: true, owner: publicOwner(owner), selectedExecutorId: executor.id, executor }); +})); + +app.get("/api/ai-workspace/assistant/v1/threads", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const surface = req.query.surface ? sanitizeSurface(req.query.surface) : null; + const limit = sanitizeLimit(req.query.limit, 100, 200); + const threads = await listThreads(owner, { surface, limit }); + res.json({ ok: true, owner: publicOwner(owner), threads }); +})); + +app.post("/api/ai-workspace/assistant/v1/threads", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const command = sanitizeThreadCommand(req.body, { partial: false }); + const thread = await createThread(owner, command); + res.status(201).json({ ok: true, owner: publicOwner(owner), thread }); +})); + +app.get("/api/ai-workspace/assistant/v1/threads/:threadId", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const threadId = sanitizeUuid(req.params.threadId, "threadId"); + const thread = await getThread(owner, threadId); + if (!thread) { + res.status(404).json({ ok: false, error: "ai_workspace_thread_not_found" }); + return; + } + res.json({ ok: true, owner: publicOwner(owner), thread }); +})); + +app.patch("/api/ai-workspace/assistant/v1/threads/:threadId", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const threadId = sanitizeUuid(req.params.threadId, "threadId"); + const command = sanitizeThreadCommand(req.body, { partial: true }); + const thread = await updateThread(owner, threadId, command); + if (!thread) { + res.status(404).json({ ok: false, error: "ai_workspace_thread_not_found" }); + return; + } + res.json({ ok: true, owner: publicOwner(owner), thread }); +})); + +app.get("/api/ai-workspace/assistant/v1/threads/:threadId/messages", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const threadId = sanitizeUuid(req.params.threadId, "threadId"); + const limit = sanitizeLimit(req.query.limit, 200, 1000); + const cursor = optionalString(req.query.before); + const thread = await getThread(owner, threadId); + if (!thread) { + res.status(404).json({ ok: false, error: "ai_workspace_thread_not_found" }); + return; + } + const messages = await listThreadMessages(owner, threadId, { limit, before: cursor }); + res.json({ ok: true, owner: publicOwner(owner), threadId, messages }); +})); + +app.post("/api/ai-workspace/assistant/v1/threads/:threadId/messages", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const threadId = sanitizeUuid(req.params.threadId, "threadId"); + const command = sanitizeMessageCommand(req.body); + const message = await createThreadMessage(owner, threadId, command); + if (!message) { + res.status(404).json({ ok: false, error: "ai_workspace_thread_not_found" }); + return; + } + res.status(201).json({ ok: true, owner: publicOwner(owner), message }); +})); + +app.use((error, _req, res, _next) => { + const status = Number(error?.status || 500); + const message = error instanceof Error ? error.message : "internal_error"; + res.status(status >= 400 && status < 600 ? status : 500).json({ ok: false, error: message }); +}); + +await migrate(); + +httpServer.listen(config.port, "0.0.0.0", () => { + console.log(`NODE.DC AI Workspace Assistant listening on http://0.0.0.0:${config.port}`); +}); + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); + +async function listExecutors(owner) { + const result = await pool.query( + `select * + from ai_workspace_executors + where owner_key = $1 + order by updated_at desc, created_at desc`, + [owner.key] + ); + return result.rows.map(toExecutor); +} + +async function createExecutor(owner, command) { + const result = await pool.query( + `insert into ai_workspace_executors ( + id, + owner_key, + owner_user_id, + owner_email, + name, + type, + connection_mode, + endpoint, + workspace_path, + agent_port, + pairing_code, + model, + account_label, + capabilities, + status, + status_detail, + last_seen_at, + metadata + ) values ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14::jsonb, $15, $16, $17, $18::jsonb + ) + returning *`, + [ + randomUUID(), + owner.key, + owner.userId, + owner.email, + command.name, + command.type, + command.connectionMode, + command.endpoint, + command.workspacePath, + command.agentPort, + command.pairingCode, + command.model, + command.accountLabel, + JSON.stringify(command.capabilities), + command.status, + command.statusDetail, + command.lastSeenAt, + JSON.stringify(command.metadata), + ] + ); + return toExecutor(result.rows[0]); +} + +async function updateExecutor(owner, executorId, command) { + const fields = []; + const values = [owner.key, executorId]; + const mappings = { + name: "name", + type: "type", + connectionMode: "connection_mode", + endpoint: "endpoint", + workspacePath: "workspace_path", + agentPort: "agent_port", + pairingCode: "pairing_code", + model: "model", + accountLabel: "account_label", + capabilities: "capabilities", + status: "status", + statusDetail: "status_detail", + lastSeenAt: "last_seen_at", + metadata: "metadata", + }; + + for (const [key, column] of Object.entries(mappings)) { + if (!Object.hasOwn(command, key)) continue; + const value = key === "capabilities" || key === "metadata" ? JSON.stringify(command[key]) : command[key]; + values.push(value); + fields.push(`${column} = $${values.length}${key === "capabilities" || key === "metadata" ? "::jsonb" : ""}`); + } + + if (fields.length === 0) { + return getExecutor(owner, executorId); + } + + values.push(new Date()); + const result = await pool.query( + `update ai_workspace_executors + set ${fields.join(", ")}, updated_at = $${values.length} + where owner_key = $1 and id = $2 + returning *`, + values + ); + return result.rows[0] ? toExecutor(result.rows[0]) : null; +} + +async function deleteExecutor(owner, executorId) { + const client = await pool.connect(); + try { + await client.query("begin"); + await client.query( + `update ai_workspace_owner_settings + set selected_executor_id = null, updated_at = now() + where owner_key = $1 and selected_executor_id = $2`, + [owner.key, executorId] + ); + const result = await client.query( + "delete from ai_workspace_executors where owner_key = $1 and id = $2", + [owner.key, executorId] + ); + await client.query("commit"); + return result.rowCount > 0; + } catch (error) { + await client.query("rollback"); + throw error; + } finally { + client.release(); + } +} + +async function getExecutor(owner, executorId) { + const result = await pool.query( + "select * from ai_workspace_executors where owner_key = $1 and id = $2", + [owner.key, executorId] + ); + return result.rows[0] ? toExecutor(result.rows[0]) : null; +} + +async function selectExecutor(owner, executorId) { + const executor = await getExecutor(owner, executorId); + if (!executor) return null; + await pool.query( + `insert into ai_workspace_owner_settings ( + owner_key, + owner_user_id, + owner_email, + selected_executor_id + ) values ($1, $2, $3, $4) + on conflict (owner_key) do update set + owner_user_id = excluded.owner_user_id, + owner_email = excluded.owner_email, + selected_executor_id = excluded.selected_executor_id, + updated_at = now()`, + [owner.key, owner.userId, owner.email, executorId] + ); + return executor; +} + +async function getOwnerSettings(owner) { + const result = await pool.query( + "select * from ai_workspace_owner_settings where owner_key = $1", + [owner.key] + ); + return result.rows[0] ? toOwnerSettings(result.rows[0]) : { ownerKey: owner.key, selectedExecutorId: null }; +} + +async function listThreads(owner, { surface, limit }) { + const values = [owner.key, limit]; + const surfaceSql = surface ? "and origin_surface = $3" : ""; + if (surface) values.push(surface); + const result = await pool.query( + `select * + from ai_workspace_threads + where owner_key = $1 + ${surfaceSql} + order by updated_at desc, created_at desc + limit $2`, + values + ); + return result.rows.map(toThread); +} + +async function createThread(owner, command) { + const result = await pool.query( + `insert into ai_workspace_threads ( + id, + owner_key, + owner_user_id, + owner_email, + title, + origin_surface, + active_context, + enabled_tool_packs, + linked_artifacts, + selected_executor_id, + lifecycle_state, + metadata + ) values ( + $1, $2, $3, $4, $5, $6, $7::jsonb, $8::text[], $9::jsonb, $10, $11, $12::jsonb + ) + returning *`, + [ + randomUUID(), + owner.key, + owner.userId, + owner.email, + command.title, + command.originSurface, + JSON.stringify(command.activeContext), + command.enabledToolPacks, + JSON.stringify(command.linkedArtifacts), + command.selectedExecutorId, + command.lifecycleState, + JSON.stringify(command.metadata), + ] + ); + return toThread(result.rows[0]); +} + +async function getThread(owner, threadId) { + const result = await pool.query( + "select * from ai_workspace_threads where owner_key = $1 and id = $2", + [owner.key, threadId] + ); + return result.rows[0] ? toThread(result.rows[0]) : null; +} + +async function updateThread(owner, threadId, command) { + const fields = []; + const values = [owner.key, threadId]; + const mappings = { + title: "title", + originSurface: "origin_surface", + activeContext: "active_context", + enabledToolPacks: "enabled_tool_packs", + linkedArtifacts: "linked_artifacts", + selectedExecutorId: "selected_executor_id", + lifecycleState: "lifecycle_state", + metadata: "metadata", + }; + + for (const [key, column] of Object.entries(mappings)) { + if (!Object.hasOwn(command, key)) continue; + const isJson = key === "activeContext" || key === "linkedArtifacts" || key === "metadata"; + const isArray = key === "enabledToolPacks"; + const value = isJson ? JSON.stringify(command[key]) : command[key]; + values.push(value); + fields.push(`${column} = $${values.length}${isJson ? "::jsonb" : isArray ? "::text[]" : ""}`); + } + + if (fields.length === 0) { + return getThread(owner, threadId); + } + + values.push(new Date()); + const result = await pool.query( + `update ai_workspace_threads + set ${fields.join(", ")}, updated_at = $${values.length} + where owner_key = $1 and id = $2 + returning *`, + values + ); + return result.rows[0] ? toThread(result.rows[0]) : null; +} + +async function listThreadMessages(owner, threadId, { limit, before }) { + const values = [owner.key, threadId, limit]; + const beforeSql = before ? "and m.created_at < $4::timestamptz" : ""; + if (before) values.push(before); + const result = await pool.query( + `select m.* + from ai_workspace_thread_messages m + join ai_workspace_threads t on t.id = m.thread_id + where t.owner_key = $1 + and m.thread_id = $2 + ${beforeSql} + order by m.created_at desc, m.sequence desc + limit $3`, + values + ); + return result.rows.reverse().map(toThreadMessage); +} + +async function createThreadMessage(owner, threadId, command) { + const client = await pool.connect(); + try { + await client.query("begin"); + const thread = await client.query( + "select id from ai_workspace_threads where owner_key = $1 and id = $2 for update", + [owner.key, threadId] + ); + if (thread.rowCount === 0) { + await client.query("rollback"); + return null; + } + const sequenceResult = await client.query( + "select coalesce(max(sequence), 0) + 1 as next_sequence from ai_workspace_thread_messages where thread_id = $1", + [threadId] + ); + const sequence = Number(sequenceResult.rows[0]?.next_sequence || 1); + const inserted = await client.query( + `insert into ai_workspace_thread_messages ( + id, + thread_id, + sequence, + role, + content, + payload + ) values ($1, $2, $3, $4, $5, $6::jsonb) + returning *`, + [randomUUID(), threadId, sequence, command.role, command.content, JSON.stringify(command.payload)] + ); + await client.query("update ai_workspace_threads set updated_at = now() where id = $1", [threadId]); + await client.query("commit"); + return toThreadMessage(inserted.rows[0]); + } catch (error) { + await client.query("rollback"); + throw error; + } finally { + client.release(); + } +} + +async function migrate() { + await pool.query(` + create table if not exists ai_workspace_executors ( + id uuid primary key, + owner_key text not null, + owner_user_id text, + owner_email text, + name text not null, + type text not null default 'codex-remote', + connection_mode text not null default 'hub', + endpoint text, + workspace_path text, + agent_port integer, + pairing_code text, + model text, + account_label text, + capabilities jsonb not null default '{}'::jsonb, + status text not null default 'unknown', + status_detail text, + last_seen_at timestamptz, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint ai_workspace_executors_owner_check + check (owner_user_id is not null or owner_email is not null), + constraint ai_workspace_executors_type_check + check (type in ('codex-remote', 'ndc-agent-core')), + constraint ai_workspace_executors_connection_mode_check + check (connection_mode in ('hub', 'direct')), + constraint ai_workspace_executors_status_check + check (status in ('unknown', 'online', 'offline', 'checking', 'error')) + ) + `); + + await pool.query(` + create table if not exists ai_workspace_owner_settings ( + owner_key text primary key, + owner_user_id text, + owner_email text, + selected_executor_id uuid references ai_workspace_executors(id) on delete set null, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint ai_workspace_owner_settings_owner_check + check (owner_user_id is not null or owner_email is not null) + ) + `); + + await pool.query(` + create table if not exists ai_workspace_threads ( + id uuid primary key, + owner_key text not null, + owner_user_id text, + owner_email text, + title text not null, + origin_surface text not null default 'global', + active_context jsonb not null default '{}'::jsonb, + enabled_tool_packs text[] not null default '{}'::text[], + linked_artifacts jsonb not null default '[]'::jsonb, + selected_executor_id uuid references ai_workspace_executors(id) on delete set null, + lifecycle_state text not null default 'active', + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint ai_workspace_threads_owner_check + check (owner_user_id is not null or owner_email is not null), + constraint ai_workspace_threads_surface_check + check (origin_surface in ('engine', 'ops', 'global')), + constraint ai_workspace_threads_lifecycle_check + check (lifecycle_state in ('active', 'archived')) + ) + `); + + await pool.query(` + create table if not exists ai_workspace_thread_messages ( + id uuid primary key, + thread_id uuid not null references ai_workspace_threads(id) on delete cascade, + sequence integer not null, + role text not null, + content text not null default '', + payload jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + constraint ai_workspace_thread_messages_role_check + check (role in ('user', 'assistant', 'system', 'tool')), + unique (thread_id, sequence) + ) + `); + + await pool.query("create index if not exists ai_workspace_executors_owner_idx on ai_workspace_executors(owner_key, updated_at desc)"); + await pool.query("create index if not exists ai_workspace_executors_pairing_code_idx on ai_workspace_executors(pairing_code) where pairing_code is not null"); + await pool.query("create index if not exists ai_workspace_threads_owner_surface_idx on ai_workspace_threads(owner_key, origin_surface, updated_at desc)"); + await pool.query("create index if not exists ai_workspace_thread_messages_thread_idx on ai_workspace_thread_messages(thread_id, sequence)"); +} + +function sanitizeExecutorCommand(payload, { partial }) { + const source = isPlainObject(payload) ? payload : {}; + const command = {}; + + if (!partial || Object.hasOwn(source, "name")) { + command.name = requireNonEmptyString(source.name, "name"); + } + if (!partial || Object.hasOwn(source, "type")) { + command.type = sanitizeEnum(source.type || "codex-remote", SUPPORTED_EXECUTOR_TYPES, "unsupported_executor_type"); + } + if (!partial || Object.hasOwn(source, "connectionMode") || Object.hasOwn(source, "connection_mode")) { + command.connectionMode = sanitizeEnum(source.connectionMode || source.connection_mode || "hub", SUPPORTED_CONNECTION_MODES, "unsupported_connection_mode"); + } + copyOptionalString(command, "endpoint", source.endpoint); + copyOptionalString(command, "workspacePath", source.workspacePath || source.workspace_path); + copyOptionalInteger(command, "agentPort", source.agentPort || source.agent_port, 1, 65535); + copyOptionalString(command, "pairingCode", source.pairingCode || source.pairing_code); + copyOptionalString(command, "model", source.model); + copyOptionalString(command, "accountLabel", source.accountLabel || source.account_label); + if (!partial || Object.hasOwn(source, "capabilities")) { + command.capabilities = isPlainObject(source.capabilities) ? source.capabilities : {}; + } + if (!partial || Object.hasOwn(source, "status")) { + command.status = sanitizeEnum(source.status || "unknown", SUPPORTED_EXECUTOR_STATUSES, "unsupported_executor_status"); + } + copyOptionalString(command, "statusDetail", source.statusDetail || source.status_detail); + if (Object.hasOwn(source, "lastSeenAt") || Object.hasOwn(source, "last_seen_at")) { + command.lastSeenAt = sanitizeOptionalIso(source.lastSeenAt || source.last_seen_at, "lastSeenAt"); + } + if (!partial || Object.hasOwn(source, "metadata")) { + command.metadata = isPlainObject(source.metadata) ? source.metadata : {}; + } + + return command; +} + +function sanitizeThreadCommand(payload, { partial }) { + const source = isPlainObject(payload) ? payload : {}; + const command = {}; + + if (!partial || Object.hasOwn(source, "title")) { + command.title = requireNonEmptyString(source.title || "AI Workspace Thread", "title"); + } + if (!partial || Object.hasOwn(source, "originSurface") || Object.hasOwn(source, "origin_surface")) { + command.originSurface = sanitizeSurface(source.originSurface || source.origin_surface || "global"); + } + if (!partial || Object.hasOwn(source, "activeContext") || Object.hasOwn(source, "active_context")) { + const value = source.activeContext || source.active_context; + command.activeContext = isPlainObject(value) ? value : {}; + } + if (!partial || Object.hasOwn(source, "enabledToolPacks") || Object.hasOwn(source, "enabled_tool_packs")) { + command.enabledToolPacks = sanitizeToolPacks(source.enabledToolPacks || source.enabled_tool_packs || []); + } + if (!partial || Object.hasOwn(source, "linkedArtifacts") || Object.hasOwn(source, "linked_artifacts")) { + const value = source.linkedArtifacts || source.linked_artifacts; + command.linkedArtifacts = Array.isArray(value) ? value.filter(isPlainObject) : []; + } + if (Object.hasOwn(source, "selectedExecutorId") || Object.hasOwn(source, "selected_executor_id")) { + const selectedExecutorId = source.selectedExecutorId || source.selected_executor_id; + command.selectedExecutorId = selectedExecutorId ? sanitizeUuid(selectedExecutorId, "selectedExecutorId") : null; + } + if (!partial || Object.hasOwn(source, "lifecycleState") || Object.hasOwn(source, "lifecycle_state")) { + command.lifecycleState = sanitizeEnum(source.lifecycleState || source.lifecycle_state || "active", SUPPORTED_THREAD_STATES, "unsupported_thread_state"); + } + if (!partial || Object.hasOwn(source, "metadata")) { + command.metadata = isPlainObject(source.metadata) ? source.metadata : {}; + } + + return command; +} + +function sanitizeMessageCommand(payload) { + const source = isPlainObject(payload) ? payload : {}; + return { + role: sanitizeEnum(source.role || "user", SUPPORTED_MESSAGE_ROLES, "unsupported_message_role"), + content: optionalString(source.content) || "", + payload: isPlainObject(source.payload) ? source.payload : {}, + }; +} + +function getRequestOwner(req) { + const userId = optionalString(req.headers["x-nodedc-user-id"] || req.query.userId); + const email = normalizeEmail(req.headers["x-nodedc-user-email"] || req.query.email); + if (!userId && !email) { + throw badRequest("ai_workspace_owner_required"); + } + return { + key: userId ? `user:${userId}` : `email:${email}`, + userId, + email, + }; +} + +function publicOwner(owner) { + return { + ownerKey: owner.key, + userId: owner.userId, + email: owner.email, + }; +} + +function toExecutor(row) { + return { + id: row.id, + ownerKey: row.owner_key, + ownerUserId: row.owner_user_id, + ownerEmail: row.owner_email, + name: row.name, + type: row.type, + connectionMode: row.connection_mode, + endpoint: row.endpoint, + workspacePath: row.workspace_path, + agentPort: row.agent_port, + pairingCode: row.pairing_code, + model: row.model, + accountLabel: row.account_label, + capabilities: row.capabilities || {}, + status: row.status, + statusDetail: row.status_detail, + lastSeenAt: toIso(row.last_seen_at), + metadata: row.metadata || {}, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function toOwnerSettings(row) { + return { + ownerKey: row.owner_key, + ownerUserId: row.owner_user_id, + ownerEmail: row.owner_email, + selectedExecutorId: row.selected_executor_id, + metadata: row.metadata || {}, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function toThread(row) { + return { + id: row.id, + ownerKey: row.owner_key, + ownerUserId: row.owner_user_id, + ownerEmail: row.owner_email, + title: row.title, + originSurface: row.origin_surface, + activeContext: row.active_context || {}, + enabledToolPacks: row.enabled_tool_packs || [], + linkedArtifacts: row.linked_artifacts || [], + selectedExecutorId: row.selected_executor_id, + lifecycleState: row.lifecycle_state, + metadata: row.metadata || {}, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function toThreadMessage(row) { + return { + id: row.id, + threadId: row.thread_id, + sequence: row.sequence, + role: row.role, + content: row.content, + payload: row.payload || {}, + createdAt: toIso(row.created_at), + }; +} + +function requireInternalApi(req, res, next) { + if (!config.internalAccessToken) { + res.status(503).json({ ok: false, error: "ai_workspace_assistant_token_not_configured" }); + return; + } + + const authorization = typeof req.headers.authorization === "string" ? req.headers.authorization : ""; + const bearerToken = authorization.match(/^Bearer\s+(.+)$/i)?.[1] || ""; + const headerToken = typeof req.headers["x-nodedc-internal-token"] === "string" ? req.headers["x-nodedc-internal-token"] : ""; + const requestToken = bearerToken || headerToken; + + if (!safeTokenEquals(requestToken, config.internalAccessToken)) { + res.status(401).json({ ok: false, error: "ai_workspace_assistant_unauthorized" }); + return; + } + + next(); +} + +function safeTokenEquals(actual, expected) { + if (!actual || !expected) return false; + const actualBuffer = Buffer.from(actual); + const expectedBuffer = Buffer.from(expected); + if (actualBuffer.length !== expectedBuffer.length) return false; + return timingSafeEqual(actualBuffer, expectedBuffer); +} + +function sanitizeSurface(value) { + return sanitizeEnum(value || "global", SUPPORTED_SURFACES, "unsupported_surface"); +} + +function sanitizeToolPacks(value) { + if (!Array.isArray(value)) return []; + return Array.from(new Set(value.map(normalizeKey).filter(Boolean))).filter((toolPack) => { + if (!SUPPORTED_TOOL_PACKS.has(toolPack)) throw badRequest("unsupported_tool_pack"); + return true; + }); +} + +function sanitizeEnum(value, supported, errorMessage) { + const key = normalizeKey(value); + if (!supported.has(key)) { + throw badRequest(errorMessage); + } + return key; +} + +function sanitizeLimit(value, fallback, max) { + const limit = Number(value || fallback); + if (!Number.isFinite(limit)) return fallback; + return Math.min(Math.max(Math.trunc(limit), 1), max); +} + +function sanitizeUuid(value, name) { + const text = optionalString(value); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(text || "")) { + throw badRequest(`${name}_invalid`); + } + return text; +} + +function sanitizeOptionalIso(value, name) { + const text = optionalString(value); + if (!text) return null; + const date = new Date(text); + if (Number.isNaN(date.getTime())) { + throw badRequest(`${name}_invalid`); + } + return date.toISOString(); +} + +function copyOptionalString(target, key, value) { + if (value === undefined) return; + target[key] = optionalString(value); +} + +function copyOptionalInteger(target, key, value, min, max) { + if (value === undefined) return; + if (value === null || value === "") { + target[key] = null; + return; + } + const number = Number(value); + if (!Number.isInteger(number) || number < min || number > max) { + throw badRequest(`${key}_invalid`); + } + target[key] = number; +} + +function requireNonEmptyString(value, name) { + const text = optionalString(value); + if (!text) throw badRequest(`${name}_required`); + return text; +} + +function optionalString(value) { + if (typeof value !== "string") return null; + const text = value.trim(); + return text ? text : null; +} + +function normalizeEmail(value) { + const text = optionalString(value); + return text ? text.toLowerCase() : null; +} + +function normalizeKey(value) { + return String(value || "").trim().toLowerCase().replace(/_/g, "-"); +} + +function isPlainObject(value) { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function toIso(value) { + if (!value) return null; + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} + +function badRequest(message) { + const error = new Error(message); + error.status = 400; + return error; +} + +function asyncRoute(handler) { + return (req, res, next) => { + Promise.resolve(handler(req, res, next)).catch(next); + }; +} + +function readConfig() { + const databaseUrl = + process.env.DATABASE_URL || + process.env.AI_WORKSPACE_ASSISTANT_DATABASE_URL || + "postgres://nodedc_ai_workspace:nodedc_ai_workspace@localhost:5432/nodedc_ai_workspace"; + + return { + port: Number(process.env.PORT || process.env.AI_WORKSPACE_ASSISTANT_PORT || "18082"), + databaseUrl, + databasePoolSize: Number(process.env.AI_WORKSPACE_ASSISTANT_DATABASE_POOL_SIZE || "10"), + internalAccessToken: + process.env.AI_WORKSPACE_ASSISTANT_TOKEN || + process.env.NODEDC_INTERNAL_ACCESS_TOKEN || + process.env.NODEDC_PLATFORM_SERVICE_TOKEN || + "", + }; +} + +async function shutdown() { + try { + httpServer.close(); + await pool.end(); + } finally { + process.exit(0); + } +} From e208b9f659fa8f423c6616f4301b35bd334337ef Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 9 Jun 2026 11:48:57 +0300 Subject: [PATCH 2/7] fix: align assistant executor payload with engine --- services/ai-workspace-assistant/src/server.mjs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/services/ai-workspace-assistant/src/server.mjs b/services/ai-workspace-assistant/src/server.mjs index 01a1e31..a4c2771 100644 --- a/services/ai-workspace-assistant/src/server.mjs +++ b/services/ai-workspace-assistant/src/server.mjs @@ -200,7 +200,7 @@ async function createExecutor(owner, command) { ) returning *`, [ - randomUUID(), + command.id || randomUUID(), owner.key, owner.userId, owner.email, @@ -580,6 +580,9 @@ function sanitizeExecutorCommand(payload, { partial }) { const source = isPlainObject(payload) ? payload : {}; const command = {}; + if (Object.hasOwn(source, "id")) { + command.id = sanitizeUuid(source.id, "id"); + } if (!partial || Object.hasOwn(source, "name")) { command.name = requireNonEmptyString(source.name, "name"); } @@ -596,7 +599,7 @@ function sanitizeExecutorCommand(payload, { partial }) { copyOptionalString(command, "model", source.model); copyOptionalString(command, "accountLabel", source.accountLabel || source.account_label); if (!partial || Object.hasOwn(source, "capabilities")) { - command.capabilities = isPlainObject(source.capabilities) ? source.capabilities : {}; + command.capabilities = isJsonContainer(source.capabilities) ? source.capabilities : {}; } if (!partial || Object.hasOwn(source, "status")) { command.status = sanitizeEnum(source.status || "unknown", SUPPORTED_EXECUTOR_STATUSES, "unsupported_executor_status"); @@ -859,6 +862,10 @@ function isPlainObject(value) { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } +function isJsonContainer(value) { + return Boolean(value && typeof value === "object"); +} + function toIso(value) { if (!value) return null; return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); From b59fa8d9ef0fe1138254ec48e3ed4a32bd0193f6 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 9 Jun 2026 13:22:38 +0300 Subject: [PATCH 3/7] feat: add shared ai workspace agent installer --- infra/docker-compose.dev.yml | 1 + .../ai-workspace-assistant/src/server.mjs | 106 +- .../src/templates/install-windows.ps1 | 3808 +++++++++++++++++ 3 files changed, 3905 insertions(+), 10 deletions(-) create mode 100644 services/ai-workspace-assistant/src/templates/install-windows.ps1 diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index 7dd8e18..c6c2924 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -162,6 +162,7 @@ services: PORT: 18082 DATABASE_URL: postgres://${AI_WORKSPACE_PG_USER:-nodedc_ai_workspace}:${AI_WORKSPACE_PG_PASS:-nodedc_ai_workspace}@ai-workspace-postgres:5432/${AI_WORKSPACE_PG_DB:-nodedc_ai_workspace} AI_WORKSPACE_ASSISTANT_TOKEN: ${AI_WORKSPACE_ASSISTANT_TOKEN:-dev-ai-workspace-assistant-token} + AI_WORKSPACE_HUB_PUBLIC_URL: ${AI_WORKSPACE_HUB_PUBLIC_URL:-ws://host.docker.internal:18081/api/ai-workspace/hub} expose: - "18082" ports: diff --git a/services/ai-workspace-assistant/src/server.mjs b/services/ai-workspace-assistant/src/server.mjs index a4c2771..ee23d62 100644 --- a/services/ai-workspace-assistant/src/server.mjs +++ b/services/ai-workspace-assistant/src/server.mjs @@ -1,5 +1,6 @@ import express from "express"; -import { randomUUID, timingSafeEqual } from "node:crypto"; +import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; +import { readFile } from "node:fs/promises"; import { createServer } from "node:http"; import { Pool } from "pg"; @@ -83,6 +84,42 @@ app.post("/api/ai-workspace/assistant/v1/executors/:executorId/select", requireI res.json({ ok: true, owner: publicOwner(owner), selectedExecutorId: executor.id, executor }); })); +app.get("/api/ai-workspace/assistant/v1/executors/:executorId/agent/windows.ps1", 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).send("executor_not_found"); + return; + } + const installerPort = installerPortFromQuery(req.query.port); + if (!installerPort) { + res.status(400).send("installer_port_invalid"); + return; + } + + const templateUrl = new URL("./templates/install-windows.ps1", import.meta.url); + let source = await readFile(templateUrl, "utf8"); + source = source + .replace('[string]$HubUrl = "",', `[string]$HubUrl = ${psSingle(config.hubWebSocketUrl)},`) + .replace('[string]$HubFallbackUrls = "",', `[string]$HubFallbackUrls = ${psSingle(config.hubFallbackWebSocketUrls.join(","))},`) + .replace('[string]$PairingCode = "",', `[string]$PairingCode = ${psSingle(executor.pairingCode || "")},`) + .replace('[string]$MachineName = "",', `[string]$MachineName = ${psSingle(executor.name)},`) + .replace('[int]$Port = 8787,', `[int]$Port = ${installerPort},`) + .replace('[string]$Workspace = "",', `[string]$Workspace = ${psSingle(executor.workspacePath || "")},`); + + const installerHash = createHash("sha256").update(source).digest("hex"); + const safeName = optionalString(executor.name)?.replace(/[^A-Za-z0-9._-]+/g, "_") || "NDC"; + res.setHeader("Content-Type", "application/octet-stream"); + res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); + res.setHeader("Surrogate-Control", "no-store"); + res.setHeader("X-AI-Workspace-Installer-SHA256", installerHash); + res.setHeader("Content-Disposition", `attachment; filename="${safeName}_BRIDGE-agent-install.ps1"`); + res.send(source); +})); + app.get("/api/ai-workspace/assistant/v1/threads", requireInternalApi, asyncRoute(async (req, res) => { const owner = getRequestOwner(req); const surface = req.query.surface ? sanitizeSurface(req.query.surface) : null; @@ -174,6 +211,9 @@ async function listExecutors(owner) { } async function createExecutor(owner, command) { + if (command.connectionMode === "hub" && !command.pairingCode) { + command.pairingCode = makePairingCode(); + } const result = await pool.query( `insert into ai_workspace_executors ( id, @@ -498,7 +538,7 @@ async function migrate() { pairing_code text, model text, account_label text, - capabilities jsonb not null default '{}'::jsonb, + capabilities jsonb not null default '[]'::jsonb, status text not null default 'unknown', status_detail text, last_seen_at timestamptz, @@ -599,7 +639,7 @@ function sanitizeExecutorCommand(payload, { partial }) { copyOptionalString(command, "model", source.model); copyOptionalString(command, "accountLabel", source.accountLabel || source.account_label); if (!partial || Object.hasOwn(source, "capabilities")) { - command.capabilities = isJsonContainer(source.capabilities) ? source.capabilities : {}; + command.capabilities = sanitizeCapabilities(source.capabilities); } if (!partial || Object.hasOwn(source, "status")) { command.status = sanitizeEnum(source.status || "unknown", SUPPORTED_EXECUTOR_STATUSES, "unsupported_executor_status"); @@ -666,7 +706,7 @@ function getRequestOwner(req) { throw badRequest("ai_workspace_owner_required"); } return { - key: userId ? `user:${userId}` : `email:${email}`, + key: email ? `email:${email}` : `user:${userId}`, userId, email, }; @@ -695,7 +735,7 @@ function toExecutor(row) { pairingCode: row.pairing_code, model: row.model, accountLabel: row.account_label, - capabilities: row.capabilities || {}, + capabilities: sanitizeCapabilities(row.capabilities), status: row.status, statusDetail: row.status_detail, lastSeenAt: toIso(row.last_seen_at), @@ -787,6 +827,17 @@ function sanitizeToolPacks(value) { }); } +function sanitizeCapabilities(value) { + const items = Array.isArray(value) + ? value + : Array.isArray(value?.items) + ? value.items + : isPlainObject(value) + ? Object.entries(value).filter(([, enabled]) => enabled === true).map(([key]) => key) + : []; + return Array.from(new Set(items.map(normalizeKey).filter(Boolean))); +} + function sanitizeEnum(value, supported, errorMessage) { const key = normalizeKey(value); if (!supported.has(key)) { @@ -801,6 +852,32 @@ function sanitizeLimit(value, fallback, max) { return Math.min(Math.max(Math.trunc(limit), 1), max); } +function normalizePairingCode(value) { + return String(value || "").replace(/[^A-Za-z0-9]/g, "").toUpperCase().slice(0, 32); +} + +function cleanPairingCode(value) { + const normalized = normalizePairingCode(value); + if (!normalized) return ""; + return normalized.replace(/(.{4})(?=.)/g, "$1-"); +} + +function makePairingCode() { + return cleanPairingCode(randomUUID().replace(/-/g, "").slice(0, 12)); +} + +function installerPortFromQuery(raw) { + const value = optionalString(raw); + if (!value) return 8787; + if (!/^\d{1,5}$/.test(value)) return null; + const port = Number(value); + return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null; +} + +function psSingle(value) { + return `'${String(value || "").replace(/'/g, "''")}'`; +} + function sanitizeUuid(value, name) { const text = optionalString(value); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(text || "")) { @@ -821,7 +898,7 @@ function sanitizeOptionalIso(value, name) { function copyOptionalString(target, key, value) { if (value === undefined) return; - target[key] = optionalString(value); + target[key] = key === "pairingCode" ? cleanPairingCode(value) : optionalString(value); } function copyOptionalInteger(target, key, value, min, max) { @@ -862,10 +939,6 @@ function isPlainObject(value) { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } -function isJsonContainer(value) { - return Boolean(value && typeof value === "object"); -} - function toIso(value) { if (!value) return null; return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); @@ -893,6 +966,19 @@ function readConfig() { port: Number(process.env.PORT || process.env.AI_WORKSPACE_ASSISTANT_PORT || "18082"), databaseUrl, databasePoolSize: Number(process.env.AI_WORKSPACE_ASSISTANT_DATABASE_POOL_SIZE || "10"), + hubWebSocketUrl: + process.env.AI_WORKSPACE_HUB_PUBLIC_URL || + process.env.NDC_AI_WORKSPACE_HUB_URL || + process.env.AI_WORKSPACE_HUB_URL || + "wss://ai-hub.nodedc.ru/api/ai-workspace/hub", + hubFallbackWebSocketUrls: String( + process.env.AI_WORKSPACE_HUB_FALLBACK_URLS || + process.env.NDC_AI_WORKSPACE_HUB_FALLBACK_URLS || + "" + ) + .split(/[\n,;]+/) + .map((item) => item.trim()) + .filter(Boolean), internalAccessToken: process.env.AI_WORKSPACE_ASSISTANT_TOKEN || process.env.NODEDC_INTERNAL_ACCESS_TOKEN || diff --git a/services/ai-workspace-assistant/src/templates/install-windows.ps1 b/services/ai-workspace-assistant/src/templates/install-windows.ps1 new file mode 100644 index 0000000..81ec5ce --- /dev/null +++ b/services/ai-workspace-assistant/src/templates/install-windows.ps1 @@ -0,0 +1,3808 @@ +# NDC AI Workspace Bridge installer for Windows. +# Run from an elevated PowerShell session: +# powershell -NoProfile -ExecutionPolicy Bypass -File .\install-windows.ps1 + +[CmdletBinding()] +param( + [int]$Port = 8787, + [string]$InstallRoot = "$env:LOCALAPPDATA\NDC\AIWorkspaceBridge", + [string]$Workspace = "", + [string]$HubUrl = "", + [string]$HubFallbackUrls = "", + [string]$PairingCode = "", + [string]$MachineName = "", + [switch]$NoAutostart, + [switch]$NoFirewall, + [switch]$NoCodexLogin, + [switch]$EnableWatchdog, + [switch]$Confirmed +) + +$ErrorActionPreference = 'Stop' +$TaskName = 'NDC AI Workspace Bridge' +$HealthPath = '/api/ai-workspace/bridge/v1/health' +$ConversationPath = '/api/ai-workspace/bridge/v1/conversations' + +function Get-ResultPath { + if ($PSScriptRoot -and (Test-Path $PSScriptRoot)) { + return (Join-Path $PSScriptRoot 'install-result.txt') + } + return (Join-Path $InstallRoot 'install-result.txt') +} + +function Write-InstallResult { + param([string]$Text) + $resultPath = Get-ResultPath + try { + $resultDir = Split-Path -Parent $resultPath + if ($resultDir) { New-Item -ItemType Directory -Force -Path $resultDir | Out-Null } + Set-Content -Path $resultPath -Value $Text -Encoding UTF8 + Write-Host "" + Write-Host "Result file: $resultPath" -ForegroundColor Green + } catch { + Write-Warn "Could not write result file: $($_.Exception.Message)" + } +} + +function Write-Step { + param([string]$Text) + Write-Host "" + Write-Host "== $Text ==" -ForegroundColor Cyan +} + +function Write-Ok { + param([string]$Text) + Write-Host "[ok] $Text" -ForegroundColor Green +} + +function Write-Warn { + param([string]$Text) + Write-Host "[warn] $Text" -ForegroundColor Yellow +} + +function Test-Admin { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Refresh-Path { + $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $env:Path = "$machinePath;$userPath" +} + +function Add-UserPath { + param([string]$PathToAdd) + if (-not $PathToAdd -or -not (Test-Path $PathToAdd)) { return } + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $parts = @() + if ($userPath) { $parts = $userPath -split ';' | Where-Object { $_ } } + if ($parts -notcontains $PathToAdd) { + $nextPath = ($parts + $PathToAdd) -join ';' + [Environment]::SetEnvironmentVariable('Path', $nextPath, 'User') + } + Refresh-Path +} + +function Resolve-CommandSource { + param([string]$Name) + $command = Get-Command $Name -ErrorAction SilentlyContinue + if ($command) { return $command.Source } + return $null +} + +function Get-NpmPath { + $command = Get-Command 'npm.cmd' -ErrorAction SilentlyContinue + if (-not $command) { $command = Get-Command 'npm' -ErrorAction SilentlyContinue } + if ($command) { return $command.Source } + return $null +} + +function Invoke-NpmInstallCodex { + $npmPath = Get-NpmPath + if (-not $npmPath) { throw 'npm was not found.' } + & $npmPath 'install' '-g' '@openai/codex' '--no-audit' '--no-fund' + if ($LASTEXITCODE -ne 0) { + throw "npm install -g @openai/codex exited with code $LASTEXITCODE" + } +} + +function Get-NpmGlobalPrefix { + $npmPath = Get-NpmPath + if (-not $npmPath) { throw 'npm was not found.' } + $prefix = (& $npmPath 'prefix' '-g') -join '' + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($prefix)) { + throw "npm prefix -g exited with code $LASTEXITCODE" + } + return $prefix.Trim() +} + +function Get-NpmVersion { + $npmPath = Get-NpmPath + if (-not $npmPath) { return $null } + return ((& $npmPath '--version') -join '').Trim() +} + +function ConvertTo-PsSingleQuoted { + param([string]$Value) + return "'" + ($Value -replace "'", "''") + "'" +} + +function Get-HubUrlCandidates { + $items = @() + if ($HubUrl) { $items += $HubUrl } + if ($HubFallbackUrls) { + $items += ($HubFallbackUrls -split '[,;\r\n]+' | Where-Object { $_ }) + } + $result = @() + foreach ($item in $items) { + $clean = ([string]$item).Trim() + if (-not $clean) { continue } + if ($result -notcontains $clean) { $result += $clean } + } + return @($result) +} + +function Test-BridgeWorkspaceCandidate { + param([string]$PathValue) + if (-not $PathValue) { return $false } + try { + $leaf = Split-Path -Leaf $PathValue + if ($leaf -and $leaf.Equals('ai-workspace-bridge', [StringComparison]::OrdinalIgnoreCase)) { return $true } + if ($InstallRoot -and $PathValue.StartsWith($InstallRoot, [StringComparison]::OrdinalIgnoreCase)) { return $true } + } catch {} + return $false +} + +function Resolve-WorkspaceCandidate { + param([string]$PathValue) + if (-not $PathValue) { return $null } + $candidate = $PathValue.Trim('"') + if (-not (Test-Path $candidate)) { return $candidate } + if (-not (Test-BridgeWorkspaceCandidate $candidate)) { return $candidate } + try { + $parent = Split-Path -Parent $candidate + if ($parent -and (Test-Path $parent)) { return $parent } + } catch {} + return $candidate +} + +function Get-DefaultWorkspaceCandidate { + if ($Workspace) { return $Workspace.Trim('"') } + + try { + $location = Get-Location + if ($location.Provider.Name -eq 'FileSystem' -and (Test-Path $location.ProviderPath)) { + $windowsRoot = [Environment]::GetFolderPath('Windows') + if (-not $windowsRoot -or -not $location.ProviderPath.StartsWith($windowsRoot, [StringComparison]::OrdinalIgnoreCase)) { + return (Resolve-WorkspaceCandidate $location.ProviderPath) + } + } + } catch {} + + if ($PSCommandPath) { + $scriptDir = Split-Path -Parent $PSCommandPath + if ($scriptDir -and (Test-Path $scriptDir)) { return (Resolve-WorkspaceCandidate $scriptDir) } + } + + return $env:USERPROFILE +} + +function Request-Elevation { + if (Test-Admin) { return } + if (-not $PSCommandPath) { + throw 'This installer needs Administrator rights when pasted directly. Save it as install-windows.ps1 and run it again.' + } + + Write-Warn 'Administrator rights are needed for Node install and Windows Firewall rule.' + $workspaceForElevated = Get-DefaultWorkspaceCandidate + $args = @( + '-NoProfile', + '-NoExit', + '-ExecutionPolicy', 'Bypass', + '-File', "`"$PSCommandPath`"", + '-Port', $Port, + '-InstallRoot', "`"$InstallRoot`"", + '-Workspace', "`"$workspaceForElevated`"", + '-Confirmed' + ) + if ($NoAutostart) { $args += '-NoAutostart' } + if ($NoFirewall) { $args += '-NoFirewall' } + if ($NoCodexLogin) { $args += '-NoCodexLogin' } + if ($EnableWatchdog) { $args += '-EnableWatchdog' } + if ($HubUrl) { $args += @('-HubUrl', "`"$HubUrl`"") } + if ($HubFallbackUrls) { $args += @('-HubFallbackUrls', "`"$HubFallbackUrls`"") } + if ($PairingCode) { $args += @('-PairingCode', "`"$PairingCode`"") } + if ($MachineName) { $args += @('-MachineName', "`"$MachineName`"") } + + Write-Host 'Opening an Administrator PowerShell window. Approve the Windows UAC prompt and continue there.' -ForegroundColor Yellow + Start-Process -FilePath 'powershell.exe' -ArgumentList ($args -join ' ') -Verb RunAs -Wait + Write-Host 'Administrator installer window finished. You can close this window.' -ForegroundColor Green + exit 0 +} + +function Install-NodeWithWinget { + $winget = Resolve-CommandSource 'winget' + if (-not $winget) { return $false } + + Write-Step 'Installing Node.js LTS with winget' + & $winget install --id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements + if ($LASTEXITCODE -eq 0) { + Refresh-Path + return $true + } + + Write-Warn 'winget did not finish successfully; falling back to direct Node.js MSI.' + return $false +} + +function Install-NodeWithMsi { + Write-Step 'Installing Node.js LTS from nodejs.org' + $arch = 'win-x64-msi' + if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64') { + $arch = 'win-arm64-msi' + } elseif ($env:PROCESSOR_ARCHITECTURE -eq 'x86' -and -not $env:PROCESSOR_ARCHITEW6432) { + $arch = 'win-x86-msi' + } + + $index = Invoke-RestMethod -Uri 'https://nodejs.org/dist/index.json' + $release = $index | Where-Object { $_.lts -ne $false -and $_.files -contains $arch } | Select-Object -First 1 + if (-not $release) { throw "Could not find a Node.js LTS MSI for $arch" } + + $tempDir = Join-Path $env:TEMP 'ndc-ai-workspace-bridge' + New-Item -ItemType Directory -Force -Path $tempDir | Out-Null + $msi = Join-Path $tempDir "node-$($release.version)-$arch.msi" + $url = "https://nodejs.org/dist/$($release.version)/node-$($release.version)-$arch.msi" + Invoke-WebRequest -Uri $url -OutFile $msi + + $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList "/i `"$msi`" /qn /norestart" -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "Node.js MSI failed with code $($process.ExitCode)" + } + Refresh-Path +} + +function Ensure-Node { + Write-Step 'Checking Node.js' + Refresh-Path + if (Resolve-CommandSource 'node') { + Write-Ok "Node found: $(& node --version)" + } else { + if (-not (Install-NodeWithWinget)) { + Install-NodeWithMsi + } + } + + Refresh-Path + if (-not (Resolve-CommandSource 'node')) { throw 'Node.js was not found after installation.' } + if (-not (Get-NpmPath)) { throw 'npm was not found after Node.js installation.' } + Write-Ok "npm found: $(Get-NpmVersion)" +} + +function Ensure-Codex { + Write-Step 'Checking Codex CLI' + Refresh-Path + if (-not (Resolve-CommandSource 'codex')) { + Invoke-NpmInstallCodex + Refresh-Path + } + + $npmPrefix = Get-NpmGlobalPrefix + Add-UserPath $npmPrefix + Refresh-Path + + if (-not (Resolve-CommandSource 'codex')) { + $codexCmd = Join-Path $npmPrefix 'codex.cmd' + if (Test-Path $codexCmd) { + Add-UserPath $npmPrefix + } + } + + if (-not (Resolve-CommandSource 'codex')) { + throw 'Codex CLI was installed, but codex is not available in PATH.' + } + + try { + $version = (& codex --version) -join ' ' + Write-Ok "Codex found: $version" + } catch { + Write-Ok 'Codex found.' + } +} + +function Ensure-CodexLogin { + if ($NoCodexLogin) { + Write-Warn 'Codex login skipped by flag.' + return + } + + $authFile = Join-Path $env:USERPROFILE '.codex\auth.json' + if (Test-Path $authFile) { + Write-Ok 'Codex auth file found.' + return + } + + Write-Step 'Codex login' + Write-Host 'A browser login flow may open. Finish it, then return to this window.' + $loginOk = $false + try { + & codex login + if ($LASTEXITCODE -eq 0) { $loginOk = $true } + } catch { + $loginOk = $false + } + + if (-not $loginOk) { + try { + & codex --login + if ($LASTEXITCODE -eq 0) { $loginOk = $true } + } catch { + $loginOk = $false + } + } + + if (-not $loginOk -and -not (Test-Path $authFile)) { + Write-Warn 'Codex login did not complete. The bridge will start, but messages will fail until Codex is logged in.' + } +} + +function Remove-TomlComment { + param([string]$Line) + $text = [string]$Line + $inSingle = $false + $inDouble = $false + $escaped = $false + for ($i = 0; $i -lt $text.Length; $i++) { + $char = $text[$i] + if ($escaped) { + $escaped = $false + continue + } + if ($inDouble -and $char -eq '\') { + $escaped = $true + continue + } + if ((-not $inDouble) -and $char -eq "'") { + $inSingle = -not $inSingle + continue + } + if ((-not $inSingle) -and $char -eq '"') { + $inDouble = -not $inDouble + continue + } + if ((-not $inSingle) -and (-not $inDouble) -and $char -eq '#') { + return $text.Substring(0, $i) + } + } + return $text +} + +function Normalize-TomlKey { + param([string]$Line) + return ((Remove-TomlComment $Line) -replace '\s', '').Replace('"', '').Replace("'", '') +} + +function Normalize-TomlHeader { + param([string]$Line) + $text = (Remove-TomlComment $Line).Trim() + if (-not $text.StartsWith('[') -or -not $text.EndsWith(']')) { return '' } + return Normalize-TomlKey $text +} + +function Has-OpsAgentInlineTransport { + param([string]$Line) + $key = Normalize-TomlKey $Line + return ($key -match '^nodedc-ops-agent=\{.*transport=') +} + +function Repair-CodexMcpConfig { + $configPath = Join-Path $env:USERPROFILE '.codex\config.toml' + if (-not (Test-Path $configPath)) { return } + + try { + $raw = Get-Content -Path $configPath -Raw -Encoding UTF8 + } catch { + Write-Warn "Could not read Codex config: $($_.Exception.Message)" + return + } + + $next = $raw + $changed = $false + + $lines = $next -split '\r?\n' + $inMcpServers = $false + $inOpsAgent = $false + $headersChanged = $false + $requiredChanged = $false + $transportChanged = $false + $hasHttpHeaders = $false + $commentDuplicateHeadersSection = $false + foreach ($line in $lines) { + $headerKey = Normalize-TomlHeader $line + if ($headerKey -eq '[mcp_servers.nodedc-ops-agent.http_headers]') { + $hasHttpHeaders = $true + break + } + } + for ($i = 0; $i -lt $lines.Count; $i++) { + $headerKey = Normalize-TomlHeader $lines[$i] + if ($commentDuplicateHeadersSection) { + if ($headerKey.StartsWith('[')) { + $commentDuplicateHeadersSection = $false + } else { + if ($lines[$i].Trim() -and -not $lines[$i].TrimStart().StartsWith('#')) { + $lines[$i] = '# ' + $lines[$i] + $headersChanged = $true + } + continue + } + } + if ($headerKey -eq '[mcp_servers.nodedc-ops-agent.headers]') { + if (-not $hasHttpHeaders) { + $lines[$i] = '[mcp_servers.nodedc-ops-agent.http_headers]' + $headersChanged = $true + $hasHttpHeaders = $true + } else { + $lines[$i] = '# ' + $lines[$i] + ' # disabled by NDC bridge installer: http_headers already exists' + $headersChanged = $true + $commentDuplicateHeadersSection = $true + } + $inOpsAgent = $false + $inMcpServers = $false + continue + } + if ($headerKey -eq '[mcp_servers.nodedc-ops-agent]') { + $inOpsAgent = $true + $inMcpServers = $false + continue + } + if ($headerKey -eq '[mcp_servers]') { + $inMcpServers = $true + $inOpsAgent = $false + continue + } + if (($inOpsAgent -or $inMcpServers) -and $headerKey.StartsWith('[')) { + $inOpsAgent = $false + $inMcpServers = $false + } + $activeLine = Remove-TomlComment $lines[$i] + if ($inOpsAgent -and $activeLine -match '^(\s*required\s*=\s*)true(\s*)$') { + $lines[$i] = $Matches[1] + 'false' + $Matches[2] + $requiredChanged = $true + } + if ($inOpsAgent -and $activeLine -match '^\s*transport\s*=') { + $lines[$i] = '# ' + $lines[$i] + ' # disabled by NDC bridge installer: Codex infers transport from url' + $transportChanged = $true + } + if ($inMcpServers -and (Has-OpsAgentInlineTransport $activeLine)) { + $lines[$i] = '# ' + $lines[$i] + ' # disabled by NDC bridge installer: Codex rejects transport here' + $transportChanged = $true + } + } + if ($headersChanged -or $requiredChanged -or $transportChanged) { + $next = $lines -join ([Environment]::NewLine) + $changed = $true + if ($headersChanged) { + Write-Ok 'Codex MCP headers normalized for nodedc-ops-agent.' + } + if ($requiredChanged) { + Write-Ok 'Codex MCP nodedc-ops-agent marked optional for bridge sessions.' + } + if ($transportChanged) { + Write-Ok 'Codex MCP transport normalized for nodedc-ops-agent.' + } + } + + if (-not $changed) { return } + + try { + $backupPath = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')" + Copy-Item -Path $configPath -Destination $backupPath -Force + Write-Ok "Codex config backup created: $backupPath" + Set-Content -Path $configPath -Value $next -Encoding UTF8 + } catch { + Write-Warn "Could not update Codex config: $($_.Exception.Message)" + } +} + +function Get-WorkspacePath { + $workspacePath = Get-DefaultWorkspaceCandidate + Write-Ok "Codex workspace: $workspacePath" + return $workspacePath +} + +function Write-WorkerFiles { + param( + [string]$Root, + [string]$WorkspacePath + ) + + Write-Step 'Writing bridge worker' + $logs = Join-Path $Root 'logs' + New-Item -ItemType Directory -Force -Path $Root | Out-Null + New-Item -ItemType Directory -Force -Path $logs | Out-Null + if (-not (Test-Path $WorkspacePath)) { + New-Item -ItemType Directory -Force -Path $WorkspacePath | Out-Null + } + + $workerPath = Join-Path $Root 'worker.mjs' + $ndcAgentMcpServerPath = Join-Path $Root 'ndcAgentMcpServer.mjs' + $startScript = Join-Path $Root 'start-bridge.ps1' + $watchdogScript = Join-Path $Root 'watchdog-bridge.ps1' + $configPath = Join-Path $Root 'config.json' + $npmPrefix = Get-NpmGlobalPrefix + $nodeSource = Resolve-CommandSource 'node' + $nodeDir = [System.IO.Path]::GetDirectoryName($nodeSource) + $logPath = Join-Path $logs 'bridge.log' + $activeMirror = Join-Path $WorkspacePath '.nodedc\ai-workspace' + $resolvedMachineName = if ($MachineName) { $MachineName } else { $env:COMPUTERNAME } + $hubUrlCandidates = @(Get-HubUrlCandidates) + $hubUrlsText = ($hubUrlCandidates -join ',') + + $workerCode = @' +#!/usr/bin/env node +import http from 'node:http' +import os from 'node:os' +import path from 'node:path' +import fs from 'node:fs/promises' +import fssync from 'node:fs' +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const HOST = process.env.AI_BRIDGE_HOST || '0.0.0.0' +const PORT = Number(process.env.AI_BRIDGE_PORT || 8787) +const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), '.codex') +const SESSION_INDEX = process.env.AI_BRIDGE_SESSION_INDEX || path.join(CODEX_HOME, 'session_index.jsonl') +const SESSIONS_DIR = process.env.AI_BRIDGE_SESSIONS_DIR || path.join(CODEX_HOME, 'sessions') +const CODEX_BIN = process.env.AI_BRIDGE_CODEX_BIN || 'codex' +const DEFAULT_CODEX_ARGS = '["exec","--json","--skip-git-repo-check","-c","model_reasoning_summary=detailed","-c","model_supports_reasoning_summaries=true","-c","use_experimental_reasoning_summary=true","-c","hide_agent_reasoning=false","-c","show_raw_agent_reasoning=false","-"]' +const CODEX_ARGS = parseArgs(process.env.AI_BRIDGE_CODEX_ARGS || DEFAULT_CODEX_ARGS) +const CODEX_RESUME_ARGS = parseArgs(process.env.AI_BRIDGE_CODEX_RESUME_ARGS || '') +const CODEX_CWD = process.env.AI_BRIDGE_CODEX_CWD || process.cwd() +const MESSAGE_TIMEOUT_MS = Number(process.env.AI_BRIDGE_MESSAGE_TIMEOUT_MS || 12 * 60 * 60 * 1000) +const RESUME_TIMEOUT_MS = Number(process.env.AI_BRIDGE_RESUME_TIMEOUT_MS || MESSAGE_TIMEOUT_MS) +const MAX_BODY_BYTES = Number(process.env.AI_BRIDGE_MAX_BODY_BYTES || 1024 * 1024) +const MAX_STDIO_BYTES = Number(process.env.AI_BRIDGE_MAX_STDIO_BYTES || 2 * 1024 * 1024) +const MAX_CONVERSATIONS = Number(process.env.AI_BRIDGE_MAX_CONVERSATIONS || 100) +const MAX_SESSION_FILES = Number(process.env.AI_BRIDGE_MAX_SESSION_FILES || 5000) +const MAX_SESSION_FILE_BYTES = Number(process.env.AI_BRIDGE_MAX_SESSION_FILE_BYTES || 64 * 1024 * 1024) +const MAX_MESSAGES_PER_CONVERSATION = Number(process.env.AI_BRIDGE_MAX_MESSAGES_PER_CONVERSATION || 300) +const MAX_MESSAGE_CHARS = Number(process.env.AI_BRIDGE_MAX_MESSAGE_CHARS || 12000) +const MAX_WORKSPACES = Number(process.env.AI_BRIDGE_MAX_WORKSPACES || 120) +const REASONING_SUMMARY_DELTA_MIN_CHARS = Number(process.env.AI_BRIDGE_REASONING_SUMMARY_DELTA_MIN_CHARS || 80) +const REASONING_SUMMARY_DELTA_MAX_WAIT_MS = Number(process.env.AI_BRIDGE_REASONING_SUMMARY_DELTA_MAX_WAIT_MS || 1800) +const CODEX_STOP_GRACE_MS = Number(process.env.AI_BRIDGE_CODEX_STOP_GRACE_MS || 2500) +const STALE_RUNNING_SESSION_MS = Number(process.env.AI_BRIDGE_STALE_RUNNING_SESSION_MS || 10 * 60 * 1000) +const FINAL_MESSAGE_PHASES = new Set(['final', 'final_answer', 'answer']) +const BRIDGE_FILE = fileURLToPath(import.meta.url) +const BRIDGE_DIR = path.dirname(BRIDGE_FILE) +const NDC_AGENT_CODEX_HOME = path.resolve(process.env.AI_BRIDGE_NDC_AGENT_CODEX_HOME || path.join(BRIDGE_DIR, 'codex-home-ndc-agent-core')) +const DEFAULT_NDC_AGENT_MCP_API_BASE = 'http://127.0.0.1:3001/api/ndc-agent-mcp' +const HUB_URL = String(process.env.AI_BRIDGE_HUB_URL || '').trim() +const HUB_URLS = parseHubUrls(process.env.AI_BRIDGE_HUB_URLS || '', HUB_URL) +const PAIRING_CODE = String(process.env.AI_BRIDGE_PAIRING_CODE || '').trim() +const MACHINE_NAME = String(process.env.AI_BRIDGE_MACHINE_NAME || os.hostname()).trim() +const HUB_RECONNECT_MS = Number(process.env.AI_BRIDGE_HUB_RECONNECT_MS || 5000) +const HUB_CONNECT_TIMEOUT_MS = Number(process.env.AI_BRIDGE_HUB_CONNECT_TIMEOUT_MS || 10000) +const hubState = { + mode: Boolean(HUB_URLS.length && PAIRING_CODE), + connected: false, + url: '', + pairingCode: PAIRING_CODE, + candidates: HUB_URLS, + lastConnectedAt: '', + lastDisconnectedAt: '', + lastError: '', +} +const ACTIVE_MIRROR_ENABLED = process.env.AI_BRIDGE_ACTIVE_MIRROR !== '0' +const ACTIVE_MIRROR_DIR = resolveWorkspacePath(process.env.AI_BRIDGE_ACTIVE_MIRROR_DIR || path.join('.nodedc', 'ai-workspace')) +const ACTIVE_MIRROR_CURRENT_FILE = path.join(ACTIVE_MIRROR_DIR, 'current.md') +const ACTIVE_MIRROR_EVENTS_FILE = path.join(ACTIVE_MIRROR_DIR, 'events.jsonl') +const ACTIVE_MIRROR_MAX_EVENTS = Number(process.env.AI_BRIDGE_ACTIVE_MIRROR_MAX_EVENTS || 80) +const ACTIVE_MIRROR_OPEN_IDE = process.env.AI_BRIDGE_ACTIVE_MIRROR_OPEN_IDE !== '0' +const ACTIVE_MIRROR_IDE_BIN = String(process.env.AI_BRIDGE_ACTIVE_MIRROR_IDE_BIN || 'code').trim() +const NDC_AGENT_MCP_SERVER = resolveWorkspacePath(defaultNdcAgentMcpServerPath()) +const NDC_AGENT_MCP_REF_PATH = process.env.AI_BRIDGE_NDC_AGENT_MCP_REF_PATH || + path.resolve(path.dirname(NDC_AGENT_MCP_SERVER), '..', '..', '..', 'tools', 'NDCMCP') +const activeCodexRunsByRequest = new Map() +const activeCodexRunsByThread = new Map() + +function defaultNdcAgentMcpServerPath() { + const explicit = String(process.env.AI_BRIDGE_NDC_AGENT_MCP_SERVER || '').trim() + if (explicit) return explicit + const installed = path.join(BRIDGE_DIR, 'ndcAgentMcpServer.mjs') + if (fssync.existsSync(installed)) return installed + const cwdLeaf = path.basename(String(CODEX_CWD || '').replace(/[\\/]+$/, '')) + if (cwdLeaf === 'nodedc-source') return path.join('server', 'aiWorkspace', 'ndcAgentMcpServer.mjs') + return path.join('nodedc-source', 'server', 'aiWorkspace', 'ndcAgentMcpServer.mjs') +} + +function resolveWorkspacePath(value) { + const text = String(value || '').trim() + if (!text) return CODEX_CWD + return path.isAbsolute(text) ? text : path.resolve(CODEX_CWD, text) +} + +async function resolveExistingDirectory(value, fallback = CODEX_CWD) { + const fallbackPath = resolveWorkspacePath(fallback) + const candidate = resolveWorkspacePath(value || fallback) + try { + const stat = await fs.stat(candidate) + if (stat.isDirectory()) return candidate + } catch {} + return fallbackPath +} + +function workspaceTitle(value) { + const normalized = String(value || '').replace(/[\\/]+$/, '') + return path.basename(normalized) || normalized || 'Workspace' +} + +function workspaceId(value) { + return String(value || '').trim() +} + +function parseHubUrls(raw, primary = '') { + const values = [] + const push = (value) => { + const text = String(value || '').trim() + if (!text) return + try { + const url = new URL(text) + if (url.protocol !== 'ws:' && url.protocol !== 'wss:') return + url.username = '' + url.password = '' + const clean = url.toString().replace(/\/+$/, '') + if (!values.includes(clean)) values.push(clean) + } catch {} + } + push(primary) + const text = String(raw || '').trim() + if (!text) return values + try { + const parsed = JSON.parse(text) + if (Array.isArray(parsed)) { + parsed.forEach(push) + return values + } + } catch {} + text.split(/[\n,;]+/).forEach(push) + return values +} + +function nowMs() { + return Date.now() +} + +function elapsedLabel(startedAt) { + const elapsed = Math.max(0, nowMs() - Number(startedAt || nowMs())) + if (elapsed < 1000) return `${elapsed}ms` + return `${(elapsed / 1000).toFixed(1)}s` +} + +function elapsedFromIso(value) { + const startedAt = Date.parse(String(value || '')) + if (!Number.isFinite(startedAt)) return '' + return elapsedLabel(startedAt) +} + +function cleanRunKey(value, limit = 160) { + return String(value || '').trim().slice(0, limit) +} + +function registerActiveCodexRun(control = {}, child, onEvent = () => {}) { + const requestId = cleanRunKey(control.requestId, 120) + const threadId = cleanRunKey(control.threadId, 160) + if (!requestId && !threadId) return null + const run = { + requestId, + threadId, + child, + onEvent, + closed: false, + stopRequested: false, + stopTimer: null, + stop() { + if (run.closed) return false + if (run.stopRequested) return true + run.stopRequested = true + try { + run.onEvent({ kind: 'stopped', message: 'Codex stop requested.' }) + } catch {} + try { + run.child.kill('SIGTERM') + } catch {} + run.stopTimer = setTimeout(() => { + if (run.closed) return + try { + run.child.kill('SIGKILL') + } catch {} + }, CODEX_STOP_GRACE_MS) + return true + }, + unregister() { + run.closed = true + if (run.stopTimer) clearTimeout(run.stopTimer) + if (requestId && activeCodexRunsByRequest.get(requestId) === run) activeCodexRunsByRequest.delete(requestId) + if (threadId && activeCodexRunsByThread.get(threadId) === run) activeCodexRunsByThread.delete(threadId) + }, + } + if (requestId) activeCodexRunsByRequest.set(requestId, run) + if (threadId) activeCodexRunsByThread.set(threadId, run) + return run +} + +function stopActiveCodexRun(payload = {}) { + const requestId = cleanRunKey(payload?.requestId, 120) + const threadId = cleanRunKey(payload?.threadId, 160) + const run = (requestId ? activeCodexRunsByRequest.get(requestId) : null) + || (threadId ? activeCodexRunsByThread.get(threadId) : null) + || null + if (!run) { + return { + ok: true, + stopped: false, + reason: 'active_codex_run_not_found', + requestId, + threadId, + } + } + const stopped = run.stop() + return { + ok: true, + stopped, + requestId: run.requestId, + threadId: run.threadId, + } +} + +function parseArgs(raw) { + const text = String(raw || '').trim() + if (!text) return [] + try { + const parsed = JSON.parse(text) + if (Array.isArray(parsed)) return parsed.map((item) => String(item)) + } catch {} + return text.split(/\s+/).filter(Boolean) +} + +function sendJson(res, status, payload) { + const body = JSON.stringify(payload) + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + 'Access-Control-Allow-Origin': process.env.AI_BRIDGE_ALLOW_ORIGIN || '*', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', + }) + res.end(body) +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let size = 0 + let body = '' + req.setEncoding('utf8') + req.on('data', (chunk) => { + size += Buffer.byteLength(chunk) + if (size > MAX_BODY_BYTES) { + reject(new Error('body_too_large')) + req.destroy() + return + } + body += chunk + }) + req.on('end', () => { + if (!body.trim()) return resolve({}) + try { + resolve(JSON.parse(body)) + } catch { + reject(new Error('json_invalid')) + } + }) + req.on('error', reject) + }) +} + +async function readConversations() { + let raw = '' + try { + raw = await fs.readFile(SESSION_INDEX, 'utf8') + } catch { + return [] + } + const conversations = raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + try { + const item = JSON.parse(line) + const id = String(item.id || '').trim() + if (!id) return null + return { + id, + title: String(item.thread_name || item.title || id).trim(), + updatedAt: item.updated_at || item.updatedAt || null, + } + } catch { + return null + } + }) + .filter(Boolean) + .sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))) + .slice(0, MAX_CONVERSATIONS) + + const sessionFiles = await listSessionFiles(SESSIONS_DIR) + await Promise.all(conversations.map(async (conversation) => { + const sessionFile = await findSessionFile(sessionFiles, conversation) + if (!sessionFile) { + conversation.messages = [] + return + } + conversation.sessionFile = sessionFile + const workspaceMeta = await readSessionWorkspaceMeta(sessionFile) + if (workspaceMeta?.cwd) { + conversation.workspacePath = workspaceMeta.cwd + conversation.workspaceTitle = workspaceTitle(workspaceMeta.cwd) + conversation.workspaceUpdatedAt = workspaceMeta.updatedAt || conversation.updatedAt || null + } + const sessionState = await readSessionState(sessionFile) + conversation.messages = sessionState.messages + conversation.status = sessionState.status + conversation.running = sessionState.running + conversation.sessionUpdatedAt = sessionState.updatedAt + })) + + return conversations +} + +function buildPrompt(payload) { + const context = payload?.context && typeof payload.context === 'object' ? payload.context : {} + const userMessage = String(payload?.message || '').trim() + const history = payload?.resume ? [] : normalizeHistory(payload?.history || []) + const isNdcAgentCore = String(context.modeId || '').trim() === 'ndc-agent-core' + const ndcAgentMcpApiBaseUrl = deriveNdcAgentMcpApiBaseUrl(context) + const lines = [ + 'You are connected to NODE.DC AI Workspace through AI Workspace Bridge.', + '', + 'Context:', + `- mode: ${context.modeTitle || context.modeId || 'unknown'}`, + `- workflow: ${context.workflowTitle || context.workflowId || 'unknown'}`, + `- agent node: ${context.agentNodeTitle || context.agentNodeId || 'unknown'}`, + `- role: ${context.roleTitle || context.roleId || 'unknown'}`, + `- workspace: ${context.workspacePath || payload?.workspacePath || CODEX_CWD}`, + '- use the user language for the final answer and public progress/reasoning summaries; if the user writes in Russian, write them in Russian', + '- while working, provide concise public progress/reasoning summaries when the Codex runtime exposes them', + '- public progress/reasoning summaries should say what you are checking, reading, comparing, or deciding', + '- keep public progress/reasoning summaries user-facing; do not include raw command lines, JSON, hidden chain-of-thought, or bridge transport details', + ...isNdcAgentCore ? [ + '', + 'NDC Agent Core mode contract:', + '- Treat the selected agent node as the only writable target.', + '- Do not write directly to the NDC core runtime and do not modify deployed runtime workflows by hand.', + '- The Engine second-level workflow file is the source of truth for edits.', + `- Engine API base for this target: ${ndcAgentMcpApiBaseUrl}`, + `- Selected workflowId: ${context.workflowId || 'unknown'}`, + `- 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.', + '- Do not use direct n8n/NDC core 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.', + '- Supported operations: addNode, upsertNode, updateNode, removeNode, moveNode, addConnection, removeConnection.', + '- 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.', + ] : [], + '', + ...history.length ? [ + 'Recent conversation from AI Workspace:', + ...history.map((message) => `${message.role}: ${message.text}`), + '', + ] : [], + 'User message:', + userMessage, + ] + return lines.join('\n') +} + +async function listSessionFiles(rootDir) { + const files = [] + async function walk(dir) { + if (files.length >= MAX_SESSION_FILES) return + let entries = [] + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch { + return + } + entries.sort((a, b) => b.name.localeCompare(a.name)) + for (const entry of entries) { + if (files.length >= MAX_SESSION_FILES) return + const itemPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + await walk(itemPath) + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push(itemPath) + } + } + } + await walk(rootDir) + return files +} + +async function findSessionFile(files, conversation) { + const id = String(conversation?.id || '').trim() + if (!id) return '' + const updatedAt = String(conversation?.updatedAt || '').trim() + const dateMatch = updatedAt.match(/^(\d{4})-(\d{2})-(\d{2})/) + if (dateMatch) { + const dateDir = path.join(SESSIONS_DIR, dateMatch[1], dateMatch[2], dateMatch[3]) + try { + const entries = await fs.readdir(dateDir) + const match = entries.find((name) => name.endsWith('.jsonl') && name.includes(id)) + if (match) return path.join(dateDir, match) + } catch {} + } + return files.find((file) => path.basename(file).includes(id)) || '' +} + +async function readTextFileOrTail(filePath) { + const stat = await fs.stat(filePath) + if (stat.size <= MAX_SESSION_FILE_BYTES) return fs.readFile(filePath, 'utf8') + const handle = await fs.open(filePath, 'r') + try { + const size = Math.min(stat.size, MAX_SESSION_FILE_BYTES) + const buffer = Buffer.alloc(size) + await handle.read(buffer, 0, size, Math.max(0, stat.size - size)) + const text = buffer.toString('utf8') + return text.slice(text.indexOf('\n') + 1) + } finally { + await handle.close() + } +} + +async function readTextFileHead(filePath, maxBytes = 256 * 1024) { + const stat = await fs.stat(filePath) + const size = Math.min(stat.size, maxBytes) + if (size <= 0) return '' + const handle = await fs.open(filePath, 'r') + try { + const buffer = Buffer.alloc(size) + await handle.read(buffer, 0, size, 0) + return buffer.toString('utf8') + } finally { + await handle.close() + } +} + +async function readSessionWorkspaceMeta(filePath) { + let text = '' + try { + text = await readTextFileHead(filePath) + } catch { + return null + } + for (const line of text.split(/\r?\n/)) { + if (!line.trim()) continue + try { + const item = JSON.parse(line) + const payload = item?.payload || {} + if (item?.type === 'session_meta' && payload?.cwd) { + return { + cwd: String(payload.cwd || '').trim(), + updatedAt: payload.timestamp || item.timestamp || null, + } + } + const contentText = readContentText(payload?.content) + const match = String(contentText || '').match(/([\s\S]*?)<\/cwd>/i) + if (match?.[1]) { + return { + cwd: match[1].trim(), + updatedAt: item.timestamp || null, + } + } + } catch {} + } + return null +} + +function timestampMs(value) { + const parsed = Date.parse(String(value || '')) + return Number.isFinite(parsed) ? parsed : 0 +} + +function messageTimestampMs(message) { + return timestampMs(message?.createdAt) +} + +function itemTimestampMs(item) { + return timestampMs(item?.timestamp || item?.createdAt || item?.created_at || item?.payload?.createdAt || item?.payload?.created_at) +} + +function isFinalAssistantMessage(message) { + if (message?.role !== 'assistant') return false + const phase = String(message.phase || '').trim().toLowerCase() + return phase ? FINAL_MESSAGE_PHASES.has(phase) : true +} + +async function readSessionState(filePath) { + let text = '' + let updatedAt = null + try { + const stat = await fs.stat(filePath) + updatedAt = stat.mtime?.toISOString?.() || null + text = await readTextFileOrTail(filePath) + } catch { + return { messages: [], status: 'unknown', running: false, updatedAt } + } + + const responseMessages = [] + const eventMessages = [] + let latestUserAt = 0 + let latestAssistantFinalAt = 0 + let latestActivityAt = 0 + let latestFailureAt = 0 + for (const line of text.split(/\r?\n/)) { + if (!line.trim()) continue + let item = null + try { + item = JSON.parse(line) + } catch { + continue + } + const responseMessage = extractResponseMessage(item) + if (responseMessage) { + responseMessages.push(responseMessage) + const at = messageTimestampMs(responseMessage) + latestActivityAt = Math.max(latestActivityAt, at) + if (responseMessage.role === 'user') latestUserAt = Math.max(latestUserAt, at) + if (isFinalAssistantMessage(responseMessage)) latestAssistantFinalAt = Math.max(latestAssistantFinalAt, at) + continue + } + const eventMessage = extractEventMessage(item) + if (eventMessage) { + eventMessages.push(eventMessage) + const at = messageTimestampMs(eventMessage) + latestActivityAt = Math.max(latestActivityAt, at) + if (eventMessage.role === 'user') latestUserAt = Math.max(latestUserAt, at) + if (isFinalAssistantMessage(eventMessage)) latestAssistantFinalAt = Math.max(latestAssistantFinalAt, at) + } + const typeText = `${item?.type || ''} ${item?.payload?.type || ''}`.toLowerCase() + if (/error|failed|interrupted|aborted/.test(typeText)) latestFailureAt = Math.max(latestFailureAt, itemTimestampMs(item)) + } + + const latestResponseAt = responseMessages.reduce((max, message) => Math.max(max, messageTimestampMs(message)), 0) + const liveEventMessages = responseMessages.length + ? eventMessages.filter((message) => messageTimestampMs(message) > latestResponseAt) + : eventMessages + const messages = dedupeMessages([...responseMessages, ...liveEventMessages] + .sort((a, b) => messageTimestampMs(a) - messageTimestampMs(b))) + .slice(-MAX_MESSAGES_PER_CONVERSATION) + const failed = latestFailureAt > latestUserAt + const rawRunning = latestUserAt > Math.max(latestAssistantFinalAt, latestFailureAt) + const updatedAtMs = timestampMs(updatedAt) + const staleRunning = rawRunning && Number.isFinite(updatedAtMs) && Date.now() - updatedAtMs > STALE_RUNNING_SESSION_MS + const running = rawRunning && !staleRunning + const status = running ? 'running' : failed ? 'failed' : latestActivityAt ? 'completed' : 'unknown' + return { messages, status, running, updatedAt } +} + +async function readSessionMessages(filePath) { + const state = await readSessionState(filePath) + return state.messages +} + +function extractResponseMessage(item) { + const payload = item?.payload || {} + if (item?.type !== 'response_item' || payload?.type !== 'message') return null + const role = normalizeRole(payload.role) + if (!role) return null + const text = normalizeDisplayMessageText(role, readContentText(payload.content ?? payload.text ?? payload.message)) + if (!text || shouldSkipConversationText(text)) return null + return { + id: String(payload.id || item.id || `${role}-${Date.now()}-${Math.random()}`), + role, + text, + phase: String(payload.phase || item.phase || '').trim(), + createdAt: payload.created_at || payload.createdAt || item.created_at || item.createdAt || item.timestamp || null, + } +} + +function extractEventMessage(item) { + const payload = item?.payload || {} + if (item?.type !== 'event_msg') return null + const type = String(payload.type || '') + const role = type === 'user_message' ? 'user' : type === 'agent_message' ? 'assistant' : '' + if (!role) return null + const text = normalizeDisplayMessageText(role, payload.message || payload.text || '') + if (!text || shouldSkipConversationText(text)) return null + return { + id: String(payload.id || item.id || `${role}-${Date.now()}-${Math.random()}`), + role, + text, + phase: String(payload.phase || item.phase || '').trim(), + createdAt: payload.created_at || payload.createdAt || item.created_at || item.createdAt || item.timestamp || null, + } +} + +function readContentText(content) { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + return content + .map((part) => { + if (typeof part === 'string') return part + if (typeof part?.text === 'string') return part.text + if (typeof part?.content === 'string') return part.content + return '' + }) + .filter(Boolean) + .join('\n') +} + +function normalizeRole(role) { + const value = String(role || '').trim().toLowerCase() + if (value === 'user') return 'user' + if (value === 'assistant') return 'assistant' + return '' +} + +function normalizeMessageText(value) { + const text = String(value || '').replace(/\r\n/g, '\n').trim() + if (!text) return '' + return text.length > MAX_MESSAGE_CHARS ? `${text.slice(0, MAX_MESSAGE_CHARS).trimEnd()}\n\n[truncated]` : text +} + +function normalizeDisplayMessageText(role, value) { + const text = normalizeMessageText(value) + if (role !== 'user') return text + const clean = text.replace(/^user\s*\n/i, '').trim() + if (!clean.includes('You are connected to NODE.DC AI Workspace through AI Workspace Bridge.')) return text + const match = clean.match(/(?:^|\n)User message:\s*\n?([\s\S]*)$/i) + return normalizeMessageText(match?.[1] || '') +} + +function shouldSkipConversationText(text) { + return /^\s*<(environment_context|permissions instructions|app-context|collaboration_mode|apps_instructions|skills_instructions|plugins_instructions|turn_aborted)\b/i.test(text) +} + +function dedupeMessages(messages) { + const result = [] + for (const message of messages) { + const previous = result[result.length - 1] + if (previous && previous.role === message.role && previous.text === message.text) continue + result.push(message) + } + return result +} + +function normalizeHistory(history) { + if (!Array.isArray(history)) return [] + return history + .slice(-40) + .map((message) => ({ + role: normalizeRole(message?.role), + text: normalizeMessageText(message?.text || message?.content || ''), + })) + .filter((message) => message.role && message.text) +} + +function cleanSessionId(value) { + const text = String(value || '').trim() + if (!text || text.startsWith('ai-') || text.includes(':')) return '' + return /^[A-Za-z0-9._-]{8,160}$/.test(text) ? text : '' +} + +function buildResumeArgs(sessionId) { + const clean = cleanSessionId(sessionId) + if (!clean) return CODEX_ARGS + if (CODEX_RESUME_ARGS.length) { + return CODEX_RESUME_ARGS.map((item) => item.replace(/\{sessionId\}/g, clean)) + } + const stdinIndex = CODEX_ARGS.lastIndexOf('-') + const base = stdinIndex >= 0 + ? CODEX_ARGS.filter((_, index) => index !== stdinIndex) + : [...CODEX_ARGS] + if (base[0] !== 'exec') return CODEX_ARGS + return [...base, 'resume', clean, '-'] +} + +function isNdcAgentCorePayload(payload) { + const context = payload?.context && typeof payload.context === 'object' ? payload.context : {} + return String(context.modeId || '').trim() === 'ndc-agent-core' +} + +function insertBeforePromptStdin(args, extras) { + const base = Array.isArray(args) ? [...args] : [] + const stdinIndex = base.lastIndexOf('-') + if (stdinIndex < 0) return [...base, ...extras] + return [...base.slice(0, stdinIndex), ...extras, ...base.slice(stdinIndex)] +} + +function buildNdcAgentMcpContext(payload, cwd) { + const context = payload?.context && typeof payload.context === 'object' ? payload.context : {} + const apiBaseUrl = deriveNdcAgentMcpApiBaseUrl(context) + return { + workflowId: String(context.workflowId || '').trim(), + workflowTitle: String(context.workflowTitle || '').trim(), + agentNodeId: String(context.agentNodeId || '').trim(), + agentNodeTitle: String(context.agentNodeTitle || '').trim(), + roleId: String(context.roleId || '').trim(), + roleTitle: String(context.roleTitle || '').trim(), + ndcAgentMcpApiBaseUrl: apiBaseUrl.replace(/\/+$/, ''), + schemaVersion: 'v2.3.2', + n8nMcpRefPath: NDC_AGENT_MCP_REF_PATH, + workspacePath: cwd, + } +} + +function deriveNdcAgentMcpApiBaseUrl(context) { + const explicit = cleanApiBaseUrl(context?.ndcAgentMcpApiBaseUrl || context?.engineApiBaseUrl || '') + const hubDerived = deriveNdcAgentMcpApiBaseUrlFromHub() + if (!explicit) return hubDerived || DEFAULT_NDC_AGENT_MCP_API_BASE + if (!isHttpUrl(explicit)) return hubDerived || DEFAULT_NDC_AGENT_MCP_API_BASE + if (hubDerived && isProtectedNodedcEngineUrl(explicit)) return hubDerived + if (isLoopbackUrl(explicit) && hubDerived) return hubDerived + if (!explicit.endsWith('/api/ndc-agent-mcp')) return `${explicit.replace(/\/+$/, '')}/api/ndc-agent-mcp` + return explicit +} + +function cleanApiBaseUrl(value) { + const text = String(value || '').trim().replace(/\/+$/, '') + return text || '' +} + +function isLoopbackUrl(value) { + try { + const url = new URL(value) + const host = url.hostname.toLowerCase() + return host === 'localhost' || host === '0.0.0.0' || host === '::1' || host.startsWith('127.') + } catch { + return false + } +} + +function isHttpUrl(value) { + try { + const url = new URL(value) + return url.protocol === 'http:' || url.protocol === 'https:' + } catch { + return false + } +} + +function deriveNdcAgentMcpApiBaseUrlFromHub() { + return deriveNdcAgentMcpBaseFromHubUrl(hubState.url) + || HUB_URLS.map(deriveNdcAgentMcpBaseFromHubUrl).find(Boolean) + || '' +} + +function deriveNdcAgentMcpBaseFromHubUrl(value) { + try { + const url = new URL(String(value || '').trim()) + const hostname = url.hostname.toLowerCase() + if (!hostname) 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(/\/+$/, '') + if (hostname === 'ai-hub.nodedc.ru') { + const pairingCode = cleanHubPairingCode(PAIRING_CODE) + return pairingCode ? `${origin}/api/ai-workspace/hub/v1/ndc-agent-mcp/${pairingCode}` : '' + } + return `${origin}/api/ndc-agent-mcp` + } catch { + return '' + } +} + +function cleanHubPairingCode(value) { + return String(value || '').trim().toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 32) +} + +function isProtectedNodedcEngineUrl(value) { + try { + const url = new URL(String(value || '').trim()) + return url.hostname.toLowerCase() === 'engine.nodedc.ru' + } catch { + return false + } +} + +function tomlString(value) { + return JSON.stringify(String(value || '')) +} + +function stripTomlSection(raw, sectionName) { + const section = String(sectionName || '').trim() + if (!section) return String(raw || '') + const lines = String(raw || '').split(/\r?\n/) + const result = [] + let skipping = false + for (const line of lines) { + const header = normalizedTomlHeader(line) + if (header) { + skipping = header === `[${section}]` || header.startsWith(`[${section}.`) + } + if (!skipping) result.push(line) + } + return result.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() +} + +function stripTopLevelTomlKeys(raw, keyNames) { + const keys = new Set((Array.isArray(keyNames) ? keyNames : []).map((key) => String(key || '').trim()).filter(Boolean)) + if (!keys.size) return String(raw || '') + const lines = String(raw || '').split(/\r?\n/) + const result = [] + let inTable = false + for (const line of lines) { + if (normalizedTomlHeader(line)) { + inTable = true + result.push(line) + continue + } + if (!inTable) { + const match = stripTomlComment(line).match(/^\s*([A-Za-z0-9_-]+)\s*=/) + if (match && keys.has(match[1])) continue + } + result.push(line) + } + return result.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() +} + +async function copyIfExists(from, to) { + try { + await fs.copyFile(from, to) + return true + } catch (error) { + if (error?.code === 'ENOENT') return false + throw error + } +} + +function ndcAgentMcpEnvConfig(mcpContext, cwd) { + return [ + '[mcp_servers.ndc_agent_core.env]', + `NDC_AGENT_MCP_ROOT = ${tomlString(CODEX_CWD)}`, + `NDC_AGENT_MCP_CONTEXT = ${tomlString(JSON.stringify(mcpContext || {}))}`, + `NDC_AGENT_MCP_API_BASE_URL = ${tomlString(mcpContext?.ndcAgentMcpApiBaseUrl || '')}`, + `AI_BRIDGE_HUB_URL = ${tomlString(hubState.url || HUB_URL)}`, + `AI_BRIDGE_HUB_URLS = ${tomlString(HUB_URLS.join(','))}`, + `AI_BRIDGE_PAIRING_CODE = ${tomlString(PAIRING_CODE)}`, + `AI_BRIDGE_CODEX_CWD = ${tomlString(cwd || CODEX_CWD)}`, + ].join('\n') +} + +async function prepareNdcAgentCodexHome(mcpContext = {}, cwd = CODEX_CWD) { + await fs.mkdir(NDC_AGENT_CODEX_HOME, { recursive: true }) + await copyIfExists(path.join(CODEX_HOME, 'auth.json'), path.join(NDC_AGENT_CODEX_HOME, 'auth.json')) + const runtimeConfig = [ + 'approval_policy = "never"', + 'sandbox_mode = "danger-full-access"', + ].join('\n') + const ndcConfig = [ + '[mcp_servers.ndc_agent_core]', + `command = ${tomlString('node')}`, + `args = [${tomlString(NDC_AGENT_MCP_SERVER)}]`, + 'startup_timeout_sec = 20', + 'tool_timeout_sec = 120', + ].join('\n') + await fs.writeFile(path.join(NDC_AGENT_CODEX_HOME, 'config.toml'), `${runtimeConfig}\n\n${ndcConfig}\n${ndcAgentMcpEnvConfig(mcpContext, cwd)}\n`, 'utf8') + return NDC_AGENT_CODEX_HOME +} + +async function codexInvocationForPayload(baseArgs, payload, cwd) { + if (!isNdcAgentCorePayload(payload)) return { args: baseArgs, env: {} } + const mcpContext = buildNdcAgentMcpContext(payload, cwd) + const codexHome = await prepareNdcAgentCodexHome(mcpContext, cwd) + return { + args: baseArgs, + env: { + CODEX_HOME: codexHome, + NDC_AGENT_MCP_ROOT: CODEX_CWD, + NDC_AGENT_MCP_CONTEXT: JSON.stringify(mcpContext), + NDC_AGENT_MCP_API_BASE_URL: mcpContext.ndcAgentMcpApiBaseUrl, + AI_BRIDGE_PAIRING_CODE: PAIRING_CODE, + }, + } +} + +function isResumeRecoverableError(error) { + const text = String(error?.message || error || '') + return /unknown command|unrecognized|unexpected argument|invalid subcommand|usage:|failed to refresh available models|timeout waiting for child process to exit|codex_timeout|session.*not.*found|no such session|could not find.*session|session.*does not exist|no rollout found|thread\/resume.*failed|error resuming thread/i.test(text) +} + +function usesJsonOutput(args) { + return Array.isArray(args) && args.includes('--json') +} + +function truncateText(value, max = 1400) { + const text = String(value || '').trim() + return text.length > max ? `${text.slice(0, max).trim()}...` : text +} + +function markdownText(value, max = 12000) { + return truncateText(String(value || '').replace(/\r\n/g, '\n'), max) +} + +function sanitizeCodexErrorText(value, max = 1400) { + const text = String(value || '').replace(/\r\n/g, '\n').trim() + if (!text) return '' + const statusMatch = text.match(/\b(?:HTTP error:\s*)?((?:401|403|429|5\d\d)\s+[A-Za-z][A-Za-z\s-]*)\b/i) + const urlMatch = text.match(/\burl:\s*((?:https?|wss):\/\/[^\s,]+)/i) + || text.match(/\b((?:https?|wss):\/\/chatgpt\.com\/[^\s,]+)/i) + const rayMatch = text.match(/\bcf-ray:\s*([A-Za-z0-9-]+)/i) + || text.match(/\bRay ID:?\s*([A-Za-z0-9-]+)/i) + if (/chatgpt\.com\/backend-api\/codex|Unable to load site|cf-ray|Cloudflare|403 Forbidden/i.test(text)) { + return [ + `Codex backend rejected request${statusMatch ? `: ${statusMatch[1].trim()}` : ''}.`, + urlMatch ? `url: ${urlMatch[1].replace(/[)>]+$/, '')}` : '', + rayMatch ? `cf-ray: ${rayMatch[1]}` : '', + ].filter(Boolean).join(' ') + } + return truncateText(text, max) +} + +function eventText(event) { + return [ + event?.message, + event?.text, + event?.command ? `$ ${event.command}` : '', + ].map((item) => String(item || '').trim()).filter(Boolean).join('\n') +} + +function renderMirrorSignal(label, value) { + const text = markdownText(value, 1800) + return text ? `- ${label}: ${text.replace(/\n/g, '\n ')}` : '' +} + +function renderActiveMirrorMarkdown(state) { + const activity = state.events.length + ? state.events.map((event) => { + const text = markdownText(event.text, 2200) + const inline = text.includes('\n') ? `\n\n${text}\n` : ` ${text}` + return `- ${event.at} ${event.kind}:${inline}` + }).join('\n') + : '- waiting for Codex events' + const signal = [ + renderMirrorSignal('Reasoning', state.currentReasoning), + renderMirrorSignal('Command', state.currentCommand), + renderMirrorSignal('Files', state.currentFiles), + renderMirrorSignal('Tool', state.currentTool), + renderMirrorSignal('Todo', state.currentTodo), + ].filter(Boolean).join('\n') || '- waiting for first Codex signal' + return [ + '# NDC AI Workspace Active Run', + '', + `Status: ${state.status}`, + `Machine: ${state.machineName}`, + `Workspace: ${state.cwd}`, + `Mode: ${state.modeTitle || state.modeId || 'unknown'}`, + `Thread: ${state.threadTitle || state.threadId || 'unknown'}`, + `Request: ${state.requestId}`, + `Started: ${state.startedAt}`, + `Updated: ${state.updatedAt}`, + state.finishedAt ? `Finished: ${state.finishedAt}` : '', + '', + '## Active Mirror', + '', + `Current file: ${state.currentFile}`, + `Events file: ${state.eventsFile}`, + '', + '## User Message', + '', + markdownText(state.userMessage || ''), + '', + '## Current Signal', + '', + signal, + '', + '## Live Activity', + '', + activity, + '', + '## Last Assistant Output', + '', + markdownText(state.assistantText || 'No assistant output yet.'), + '', + ].filter((line) => line !== '').join('\n') +} + +function openActiveMirrorInIde(filePath) { + if (!ACTIVE_MIRROR_OPEN_IDE || !ACTIVE_MIRROR_IDE_BIN || !filePath) return + try { + const child = spawn(ACTIVE_MIRROR_IDE_BIN, ['-r', filePath], { + detached: true, + shell: process.platform === 'win32', + stdio: 'ignore', + windowsHide: true, + }) + child.unref() + } catch {} +} + +function activeMirrorPathsForCwd(cwd) { + const workspace = resolveWorkspacePath(cwd || CODEX_CWD) + const mirrorDir = workspace === CODEX_CWD + ? ACTIVE_MIRROR_DIR + : path.join(workspace, '.nodedc', 'ai-workspace') + return { + mirrorDir, + currentFile: path.join(mirrorDir, 'current.md'), + eventsFile: path.join(mirrorDir, 'events.jsonl'), + } +} + +async function hasGitMarker(dir) { + try { + await fs.stat(path.join(dir, '.git')) + return true + } catch { + return false + } +} + +async function addWorkspace(map, workspacePath, source, patch = {}) { + const cleanPath = String(workspacePath || '').trim() + if (!cleanPath) return + let stat = null + try { + stat = await fs.stat(cleanPath) + } catch { + return + } + if (!stat.isDirectory()) return + const id = workspaceId(cleanPath) + const previous = map.get(id) || {} + map.set(id, { + id, + path: cleanPath, + title: patch.title || previous.title || workspaceTitle(cleanPath), + kind: patch.kind || previous.kind || (await hasGitMarker(cleanPath) ? 'git' : 'folder'), + source: Array.from(new Set([...(previous.source ? String(previous.source).split(', ') : []), source].filter(Boolean))).join(', '), + lastUsedAt: patch.lastUsedAt || previous.lastUsedAt || null, + sessionCount: Number(previous.sessionCount || 0) + Number(patch.sessionCount || 0), + }) +} + +async function discoverWorkspaces() { + const map = new Map() + await addWorkspace(map, CODEX_CWD, 'configured', { title: `${workspaceTitle(CODEX_CWD)} (default)` }) + const conversations = await readConversations() + for (const conversation of conversations) { + if (!conversation.workspacePath) continue + await addWorkspace(map, conversation.workspacePath, 'codex-session', { + lastUsedAt: conversation.workspaceUpdatedAt || conversation.updatedAt || null, + sessionCount: 1, + }) + } + const workspaces = Array.from(map.values()) + .sort((a, b) => { + if (a.path === CODEX_CWD) return -1 + if (b.path === CODEX_CWD) return 1 + const byDate = String(b.lastUsedAt || '').localeCompare(String(a.lastUsedAt || '')) + if (byDate) return byDate + return String(a.title || '').localeCompare(String(b.title || '')) + }) + .slice(0, MAX_WORKSPACES) + return { + ok: true, + cwd: CODEX_CWD, + workspaces, + } +} + +function createActiveMirror(command, payload = {}, meta = {}) { + const context = payload?.context && typeof payload.context === 'object' ? payload.context : {} + if (!ACTIVE_MIRROR_ENABLED || command !== 'message' || context.remoteControlMode !== 'active') return null + const cwd = String(meta.cwd || CODEX_CWD) + const { mirrorDir, currentFile, eventsFile } = activeMirrorPathsForCwd(cwd) + const startedAt = new Date().toISOString() + const state = { + requestId: String(meta.requestId || payload.threadId || `local-${Date.now()}`), + threadId: String(payload.threadId || ''), + threadTitle: String(payload.threadTitle || ''), + userMessage: String(payload.message || ''), + modeId: String(context.modeId || ''), + modeTitle: String(context.modeTitle || ''), + machineName: MACHINE_NAME, + cwd, + status: 'running', + startedAt, + updatedAt: startedAt, + finishedAt: '', + assistantText: '', + currentFile, + eventsFile, + currentReasoning: '', + currentCommand: '', + currentFiles: '', + currentTool: '', + currentTodo: '', + events: [], + ideOpened: false, + } + let chain = Promise.resolve() + + const write = (row) => { + chain = chain.then(async () => { + state.updatedAt = new Date().toISOString() + await fs.mkdir(mirrorDir, { recursive: true }) + if (row) { + await fs.appendFile(eventsFile, `${JSON.stringify({ + requestId: state.requestId, + threadId: state.threadId, + threadTitle: state.threadTitle, + machineName: state.machineName, + cwd: state.cwd, + ...row, + })}\n`, 'utf8') + } + await fs.writeFile(currentFile, renderActiveMirrorMarkdown(state), 'utf8') + if (!state.ideOpened) { + state.ideOpened = true + openActiveMirrorInIde(currentFile) + } + }).catch(() => {}) + return chain + } + + const record = (event = {}) => { + const kind = String(event.kind || event.type || 'event') + const text = eventText(event) + const row = { + at: new Date().toISOString(), + kind, + text: markdownText(text, 6000), + } + if (event.currentFile) row.currentFile = String(event.currentFile) + if (event.eventsFile) row.eventsFile = String(event.eventsFile) + if (kind === 'codex_reasoning') state.currentReasoning = markdownText(event.text || text, 6000) + if (kind === 'codex_command' || kind === 'start') state.currentCommand = markdownText(event.command || text, 3000) + if (kind === 'codex_files') state.currentFiles = markdownText(event.text || text, 3000) + if (kind === 'codex_tool') state.currentTool = markdownText(event.text || text, 3000) + if (kind === 'codex_todo') state.currentTodo = markdownText(event.text || text, 3000) + if (kind === 'codex_message') state.assistantText = markdownText(event.text || text, 12000) + if (kind === 'done' || kind === 'codex_turn_completed') state.status = 'completed' + if (kind === 'timeout') state.status = 'timeout' + if (kind === 'error' && !/reconnecting/i.test(text)) state.status = 'failed' + state.events.push(row) + state.events = state.events.slice(-ACTIVE_MIRROR_MAX_EVENTS) + write(row) + } + + return { + currentFile, + eventsFile, + record, + async complete(status, assistantText = '') { + state.status = status + state.finishedAt = new Date().toISOString() + if (assistantText) state.assistantText = markdownText(assistantText, 12000) + await write({ + at: state.finishedAt, + kind: `mirror_${status}`, + text: status, + }) + }, + } +} + +function textFromUnknown(value) { + if (typeof value === 'string') return value + if (Array.isArray(value)) return value.map(textFromUnknown).filter(Boolean).join('\n') + if (value && typeof value === 'object') { + return [ + value.text, + value.summary, + value.content, + value.message, + value.delta, + ].map(textFromUnknown).filter(Boolean).join('\n') + } + return '' +} + +function reasoningTextFromItem(item) { + return textFromUnknown([ + item?.summary, + item?.summary_text, + item?.text, + item?.content, + ]) +} + +function reasoningSummaryEventKey(event) { + const itemId = String(event?.item_id || event?.item?.id || event?.response_id || 'reasoning').trim() || 'reasoning' + const summaryIndex = String(event?.summary_index ?? event?.content_index ?? event?.output_index ?? 0) + return `${itemId}:${summaryIndex}` +} + +function reasoningSummaryTextFromEvent(event) { + return textFromUnknown([ + event?.text, + event?.delta, + event?.part, + event?.summary, + event?.summary_text, + ]) +} + +function shouldEmitReasoningSummaryDelta(record, pending) { + const text = String(pending || '').trim() + if (!text) return false + if (/[.!?。!?]\s*$/.test(text)) return true + if (text.length >= REASONING_SUMMARY_DELTA_MIN_CHARS) return true + return nowMs() - Number(record.lastEmittedAt || 0) >= REASONING_SUMMARY_DELTA_MAX_WAIT_MS && text.length >= 32 +} + +function updateReasoningSummaryState(state, event, mode) { + if (!state) return null + const key = reasoningSummaryEventKey(event) + const record = state.get(key) || { text: '', emittedLength: 0, lastEmittedAt: 0 } + if (mode === 'delta') { + const delta = textFromUnknown(event?.delta) + if (!delta) return null + record.text += delta + const pending = record.text.slice(record.emittedLength).trim() + if (!shouldEmitReasoningSummaryDelta(record, pending)) { + state.set(key, record) + return null + } + record.emittedLength = record.text.length + record.lastEmittedAt = nowMs() + state.set(key, record) + return pending + } + + const text = reasoningSummaryTextFromEvent(event) + if (text) record.text = text + const pending = record.text.slice(record.emittedLength).trim() || (!record.emittedLength ? record.text.trim() : '') + record.emittedLength = record.text.length + record.lastEmittedAt = nowMs() + state.set(key, record) + return pending +} + +function createCodexJsonEventMapper() { + const reasoningSummaryState = new Map() + return (event) => codexJsonEventToBridgeEvent(event, reasoningSummaryState) +} + +function summarizeFileChanges(changes) { + const list = Array.isArray(changes) ? changes : [] + if (!list.length) return 'File changes reported.' + return list + .slice(0, 12) + .map((change) => `${String(change.kind || 'update')}: ${String(change.path || '')}`.trim()) + .join('\n') +} + +function codexJsonEventToBridgeEvent(event, reasoningSummaryState = null) { + const type = String(event?.type || '') + const item = event?.item && typeof event.item === 'object' ? event.item : null + const itemType = String(item?.type || '') + if (type === 'response.reasoning_summary_text.delta') { + const text = truncateText(updateReasoningSummaryState(reasoningSummaryState, event, 'delta')) + return text ? { kind: 'codex_reasoning', message: text } : null + } + if (type === 'response.reasoning_summary_text.done') { + const text = truncateText(updateReasoningSummaryState(reasoningSummaryState, event, 'done')) + return text ? { kind: 'codex_reasoning', message: text } : null + } + if (type === 'response.reasoning_summary_part.done' || type === 'response.reasoning_summary_part.added') { + const text = truncateText(reasoningSummaryTextFromEvent(event)) + return text ? { kind: 'codex_reasoning', message: text } : null + } + if (type === 'response.reasoning_text.delta' || type === 'response.reasoning_text.done') return null + if (type === 'thread.started') { + const sessionId = String(event.thread_id || '').trim() + return { kind: 'codex_thread', sessionId, message: `Thread started: ${sessionId}`.trim() } + } + if (type === 'turn.started') return { kind: 'codex_turn', message: 'Turn started.' } + if (type === 'turn.completed') { + const usage = event.usage || {} + const tokens = Number(usage.input_tokens || 0) + Number(usage.output_tokens || 0) + return { kind: 'codex_turn_completed', message: tokens ? `Turn completed. Tokens: ${tokens}.` : 'Turn completed.' } + } + if (type === 'turn.failed') return { kind: 'error', message: event?.error?.message || 'Turn failed.' } + if (type === 'error') return { kind: 'error', message: event.message || 'Codex stream error.' } + if (!item) return null + + const prefix = type === 'item.started' ? 'Started' : type === 'item.updated' ? 'Updated' : 'Completed' + if (itemType === 'reasoning') { + const text = truncateText(reasoningTextFromItem(item)) + return text ? { kind: 'codex_reasoning', message: text } : null + } + if (itemType === 'agent_message') return { kind: 'codex_message', message: 'Assistant response received.', text: truncateText(item.text || '') } + if (itemType === 'command_execution') { + const status = item.status ? ` - ${item.status}` : '' + return { + kind: 'codex_command', + command: item.command || '', + message: `${prefix} command${status}: ${item.command || ''}`.trim(), + text: truncateText(item.aggregated_output || ''), + } + } + if (itemType === 'file_change') { + return { + kind: 'codex_files', + message: `${prefix} file change${item.status ? ` - ${item.status}` : ''}.`, + text: summarizeFileChanges(item.changes), + } + } + if (itemType === 'mcp_tool_call') { + return { + kind: 'codex_tool', + message: `${prefix} MCP ${item.server || ''}.${item.tool || ''}${item.status ? ` - ${item.status}` : ''}`.trim(), + text: truncateText(item.error?.message || ''), + } + } + if (itemType === 'todo_list') { + const todos = Array.isArray(item.items) + ? item.items.map((todo) => `${todo.completed ? '[x]' : '[ ]'} ${todo.text || ''}`).join('\n') + : '' + return { kind: 'codex_todo', message: `${prefix} todo list.`, text: truncateText(todos) } + } + if (itemType === 'web_search') return { kind: 'codex_tool', message: `${prefix} web search: ${item.query || ''}`.trim() } + if (itemType === 'error') return { kind: 'error', message: item.message || 'Codex item error.' } + return { kind: 'codex_event', message: `${prefix} ${itemType || type}.` } +} + +function finalMessageFromCodexEvent(event) { + const item = event?.item && typeof event.item === 'object' ? event.item : null + if (!item || item.type !== 'agent_message') return '' + return String(item.text || '').trim() +} + +async function runCodexForPayload(prompt, payload, onEvent = () => {}, cwd = CODEX_CWD, meta = {}) { + const baseArgs = payload?.resume ? buildResumeArgs(payload?.threadId) : CODEX_ARGS + const invocation = await codexInvocationForPayload(baseArgs, payload, cwd) + if (isNdcAgentCorePayload(payload)) { + try { + const context = buildNdcAgentMcpContext(payload, cwd) + onEvent({ + kind: 'ndc_agent_mcp_context', + message: `NDC Agent MCP API base: ${context.ndcAgentMcpApiBaseUrl}`, + }) + } catch {} + } + const control = { + requestId: meta?.requestId, + threadId: payload?.threadId, + } + if (baseArgs === CODEX_ARGS) { + return runCodex(prompt, invocation.args, onEvent, MESSAGE_TIMEOUT_MS, cwd, control, invocation.env) + } + try { + return await runCodex(prompt, invocation.args, onEvent, RESUME_TIMEOUT_MS, cwd, control, invocation.env) + } catch (error) { + if (!isResumeRecoverableError(error)) throw error + onEvent({ kind: 'fallback', message: 'Codex resume failed; falling back to a new exec run.' }) + const fallback = await codexInvocationForPayload(CODEX_ARGS, payload, cwd) + return runCodex(prompt, fallback.args, onEvent, MESSAGE_TIMEOUT_MS, cwd, control, fallback.env) + } +} + +function runCodex(prompt, args = CODEX_ARGS, onEvent = () => {}, timeoutMs = MESSAGE_TIMEOUT_MS, cwd = CODEX_CWD, control = {}, envOverrides = {}) { + return new Promise((resolve, reject) => { + const startedAt = nowMs() + let firstOutputSeen = false + const jsonOutput = usesJsonOutput(args) + let jsonBuffer = '' + let finalJsonMessage = '' + let plainStdout = '' + const mapCodexJsonEvent = createCodexJsonEventMapper() + onEvent({ + kind: 'start', + command: `${CODEX_BIN} ${args.join(' ')}`, + cwd, + message: 'Codex process started.', + }) + const child = spawn(CODEX_BIN, args, { + cwd, + env: { ...process.env, ...(envOverrides || {}) }, + shell: process.platform === 'win32', + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }) + const activeRun = registerActiveCodexRun(control, child, onEvent) + let stdout = '' + let stderr = '' + let killed = false + const timeout = setTimeout(() => { + killed = true + onEvent({ kind: 'timeout', message: 'Codex process timed out.' }) + child.kill('SIGTERM') + }, timeoutMs) + + child.stdout.on('data', (chunk) => { + const text = String(chunk) + if (!firstOutputSeen) { + firstOutputSeen = true + onEvent({ kind: 'first_output', message: `First Codex output after ${elapsedLabel(startedAt)}.` }) + } + stdout += text + if (jsonOutput) { + jsonBuffer += text + const lines = jsonBuffer.split(/\r?\n/) + jsonBuffer = lines.pop() || '' + lines.forEach((line) => { + const trimmed = line.trim() + if (!trimmed) return + try { + const event = JSON.parse(trimmed) + const finalMessage = finalMessageFromCodexEvent(event) + if (finalMessage) finalJsonMessage = finalMessage + const bridgeEvent = mapCodexJsonEvent(event) + if (bridgeEvent) onEvent(bridgeEvent) + } catch { + plainStdout += `${line}\n` + onEvent({ kind: 'stdout', stream: 'stdout', text: line }) + } + }) + } else { + plainStdout += text + onEvent({ kind: 'stdout', stream: 'stdout', text }) + } + if (Buffer.byteLength(stdout) > MAX_STDIO_BYTES) child.kill('SIGTERM') + }) + child.stderr.on('data', (chunk) => { + const text = String(chunk) + if (!firstOutputSeen) { + firstOutputSeen = true + onEvent({ kind: 'first_output', message: `First Codex output after ${elapsedLabel(startedAt)}.` }) + } + stderr += text + onEvent({ kind: 'stderr', stream: 'stderr', text: sanitizeCodexErrorText(text) }) + if (Buffer.byteLength(stderr) > MAX_STDIO_BYTES) child.kill('SIGTERM') + }) + child.on('error', (error) => { + clearTimeout(timeout) + activeRun?.unregister() + onEvent({ kind: 'error', message: String(error?.message || error || 'codex_error') }) + reject(error) + }) + child.on('close', (code) => { + clearTimeout(timeout) + activeRun?.unregister() + if (activeRun?.stopRequested) { + onEvent({ kind: 'stopped', message: 'Codex process stopped.' }) + return resolve('') + } + const stderrMessage = sanitizeCodexErrorText(stderr.trim()) + if (killed) return reject(new Error(`codex_timeout${stderrMessage ? `: ${stderrMessage.slice(0, 1000)}` : ''}`)) + if (code !== 0) { + onEvent({ kind: 'error', message: stderrMessage || `codex_exit_${code}` }) + return reject(new Error(stderrMessage || `codex_exit_${code}`)) + } + onEvent({ kind: 'done', message: `Codex process completed in ${elapsedLabel(startedAt)}.` }) + resolve(finalJsonMessage || plainStdout.trim() || stdout.trim() || stderr.trim()) + }) + child.stdin.end(prompt) + }) +} + +function stripTomlComment(line) { + const text = String(line || '') + let inSingle = false + let inDouble = false + let escaped = false + for (let i = 0; i < text.length; i += 1) { + const char = text[i] + if (escaped) { + escaped = false + continue + } + if (inDouble && char === '\\') { + escaped = true + continue + } + if (!inDouble && char === "'") { + inSingle = !inSingle + continue + } + if (!inSingle && char === '"') { + inDouble = !inDouble + continue + } + if (!inSingle && !inDouble && char === '#') { + return text.slice(0, i) + } + } + return text +} + +function normalizedTomlKey(line) { + return stripTomlComment(line).replace(/\s+/g, '').replace(/["']/g, '') +} + +function normalizedTomlHeader(line) { + const text = stripTomlComment(line).trim() + if (!text.startsWith('[') || !text.endsWith(']')) return '' + return normalizedTomlKey(text) +} + +function hasOpsAgentInlineTransport(line) { + const text = normalizedTomlKey(line) + return /^nodedc-ops-agent=\{.*(?:^|[,}])?transport=/.test(text) +} + +async function inspectCodexRuntime() { + const configPath = path.join(CODEX_HOME, 'config.toml') + const authPath = path.join(CODEX_HOME, 'auth.json') + try { + await fs.access(authPath) + } catch { + return { + ok: false, + error: 'codex_auth_missing', + configPath, + authPath, + } + } + try { + const raw = await fs.readFile(configPath, 'utf8') + let inMcpServers = false + let inOpsAgent = false + for (const line of raw.split(/\r?\n/)) { + const header = normalizedTomlHeader(line) + if (header) { + if (header === '[mcp_servers.nodedc-ops-agent.headers]') { + return { + ok: false, + error: 'invalid_codex_mcp_headers:nodedc-ops-agent', + configPath, + authPath, + } + } + inMcpServers = header === '[mcp_servers]' + inOpsAgent = header === '[mcp_servers.nodedc-ops-agent]' + continue + } + const activeLine = stripTomlComment(line) + if ( + (inOpsAgent && /^\s*transport\s*=/.test(activeLine)) || + (inMcpServers && hasOpsAgentInlineTransport(activeLine)) + ) { + return { + ok: false, + error: 'invalid_codex_mcp_transport:nodedc-ops-agent', + configPath, + authPath, + } + } + } + } catch (error) { + if (error?.code !== 'ENOENT') { + return { + ok: false, + error: `codex_config_read_failed:${String(error?.message || error)}`, + configPath, + authPath, + } + } + } + return { ok: true, error: '', configPath, authPath } +} + +async function handleBridgeCommand(command, payload = {}, onEvent = () => {}, meta = {}) { + if (command === 'health') { + const runtime = await inspectCodexRuntime() + return { + ok: runtime.ok, + error: runtime.error, + service: 'ai-workspace-bridge', + host: os.hostname(), + machineName: MACHINE_NAME, + runtimeStatus: runtime.ok ? 'ready' : 'error', + runtimeError: runtime.error, + runtimeCheckedAt: new Date().toISOString(), + codexHome: CODEX_HOME, + codexAuthPath: runtime.authPath, + codexBin: CODEX_BIN, + codexArgs: CODEX_ARGS, + cwd: CODEX_CWD, + hubMode: hubState.mode, + hubConnected: hubState.connected, + hub: { ...hubState }, + activeMirror: { + enabled: ACTIVE_MIRROR_ENABLED, + currentFile: ACTIVE_MIRROR_CURRENT_FILE, + eventsFile: ACTIVE_MIRROR_EVENTS_FILE, + }, + } + } + + if (command === 'conversations') { + const conversations = await readConversations() + return { ok: true, conversations } + } + + if (command === 'workspaces') { + return discoverWorkspaces() + } + + if (command === 'stop') { + return stopActiveCodexRun(payload) + } + + if (command === 'message') { + const message = String(payload?.message || '').trim() + if (!message) { + const error = new Error('message_empty') + error.status = 400 + throw error + } + const runtime = await inspectCodexRuntime() + if (!runtime.ok) { + const error = new Error(runtime.error || 'codex_runtime_not_ready') + error.status = 503 + throw error + } + const cwd = await resolveExistingDirectory(payload?.workspacePath || payload?.context?.workspacePath || CODEX_CWD) + const messagePayload = { + ...payload, + workspacePath: cwd, + context: { + ...(payload?.context && typeof payload.context === 'object' ? payload.context : {}), + workspacePath: cwd, + workspaceTitle: workspaceTitle(cwd), + }, + } + const mirror = createActiveMirror(command, messagePayload, { ...meta, cwd }) + const emitEvent = (event) => { + mirror?.record(event) + onEvent(event) + } + if (mirror) { + emitEvent({ + kind: 'active_mirror', + message: `Active mirror ready: ${mirror.currentFile}`, + currentFile: mirror.currentFile, + eventsFile: mirror.eventsFile, + cwd, + }) + } + try { + const reply = await runCodexForPayload(buildPrompt(messagePayload), messagePayload, emitEvent, cwd, meta) + await mirror?.complete('completed', reply) + return { + ok: true, + threadId: String(payload.threadId || ''), + workspacePath: cwd, + workspaceTitle: workspaceTitle(cwd), + activeMirror: mirror ? { + currentFile: mirror.currentFile, + eventsFile: mirror.eventsFile, + } : null, + message: { + role: 'assistant', + text: reply, + createdAt: new Date().toISOString(), + }, + } + } catch (error) { + await mirror?.complete('failed', String(error?.message || error || 'codex_failed')) + throw error + } + } + + const error = new Error('command_unknown') + error.status = 404 + throw error +} + +async function dataToText(data) { + if (typeof data === 'string') return data + if (Buffer.isBuffer(data)) return data.toString('utf8') + if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') + if (data?.arrayBuffer) return Buffer.from(await data.arrayBuffer()).toString('utf8') + return String(data || '') +} + +function buildHubSocketUrl(hubUrl) { + const socketUrl = new URL(hubUrl) + socketUrl.searchParams.set('pairingCode', PAIRING_CODE) + socketUrl.searchParams.set('machineName', MACHINE_NAME) + return socketUrl.toString() +} + +function attachHubSocket(socket, hubUrl) { + console.log(`[ai-workspace-bridge] hub connected: ${hubUrl}`) + hubState.connected = true + hubState.url = hubUrl + hubState.lastConnectedAt = new Date().toISOString() + hubState.lastError = '' + void inspectCodexRuntime().then((runtime) => { + try { + socket.send(JSON.stringify({ + type: 'hello', + machineName: MACHINE_NAME, + host: os.hostname(), + cwd: CODEX_CWD, + runtimeStatus: runtime.ok ? 'ready' : 'error', + runtimeError: runtime.error, + runtimeCheckedAt: new Date().toISOString(), + sentAt: new Date().toISOString(), + })) + } catch {} + }).catch((error) => { + try { + socket.send(JSON.stringify({ + type: 'hello', + machineName: MACHINE_NAME, + host: os.hostname(), + cwd: CODEX_CWD, + runtimeStatus: 'error', + runtimeError: `codex_runtime_check_failed:${String(error?.message || error)}`, + runtimeCheckedAt: new Date().toISOString(), + sentAt: new Date().toISOString(), + })) + } catch {} + }) + + socket.onmessage = async (event) => { + let request = null + try { + request = JSON.parse(await dataToText(event.data)) + } catch { + return + } + if (request?.type !== 'request') return + const requestId = String(request.requestId || '') + const emitEvent = (payload) => { + try { + socket.send(JSON.stringify({ + type: 'event', + requestId, + event: { + ...(payload || {}), + at: new Date().toISOString(), + }, + })) + } catch {} + } + try { + const receivedAfter = elapsedFromIso(request?.payload?.client?.sentAt) + emitEvent({ + kind: 'bridge_received', + message: receivedAfter + ? `Bridge received request after ${receivedAfter}.` + : 'Bridge received request.', + }) + const payload = await handleBridgeCommand(String(request.command || ''), request.payload || {}, emitEvent, { requestId }) + socket.send(JSON.stringify({ type: 'response', requestId, ok: true, payload })) + } catch (error) { + emitEvent({ kind: 'error', message: String(error?.message || error || 'bridge_agent_failed') }) + socket.send(JSON.stringify({ + type: 'response', + requestId, + ok: false, + status: Number(error?.status || 502), + error: String(error?.message || error || 'bridge_agent_failed'), + })) + } + } + socket.onerror = (event) => { + const message = String(event?.message || 'websocket_error') + hubState.lastError = message + console.error(`[ai-workspace-bridge] hub error: ${message} (${hubUrl})`) + } + socket.onclose = () => { + hubState.connected = false + hubState.lastDisconnectedAt = new Date().toISOString() + hubState.lastError = 'hub_disconnected' + console.error(`[ai-workspace-bridge] hub disconnected; reconnecting in ${HUB_RECONNECT_MS}ms (${hubUrl})`) + setTimeout(() => connectHub(), HUB_RECONNECT_MS) + } +} + +function connectHub() { + if (!HUB_URLS.length || !PAIRING_CODE) return + if (typeof WebSocket === 'undefined') { + hubState.lastError = 'websocket_runtime_unavailable' + console.error('[ai-workspace-bridge] WebSocket is not available in this Node.js runtime; use Node.js 22+ for hub mode.') + return + } + + const attempts = [] + let reconnectScheduled = false + let selected = false + const scheduleReconnect = (delayMs, message) => { + if (reconnectScheduled) return + reconnectScheduled = true + if (message) { + hubState.lastError = message + console.error(message) + } + for (const attempt of attempts) { + try { attempt.socket.close() } catch {} + } + setTimeout(() => connectHub(), delayMs) + } + + const openTimer = setTimeout(() => { + if (selected) return + scheduleReconnect(1000, `[ai-workspace-bridge] hub candidates unavailable after ${HUB_CONNECT_TIMEOUT_MS}ms; retrying.`) + }, HUB_CONNECT_TIMEOUT_MS) + + for (const hubUrl of HUB_URLS) { + let socketUrl = '' + try { + socketUrl = buildHubSocketUrl(hubUrl) + } catch (error) { + const message = `invalid_hub_url:${String(error?.message || error)}` + hubState.lastError = message + console.error(`[ai-workspace-bridge] invalid hub URL: ${String(error?.message || error)} (${hubUrl})`) + continue + } + + const socket = new WebSocket(socketUrl) + const attempt = { socket, hubUrl, closed: false } + attempts.push(attempt) + socket.onopen = () => { + if (selected) { + try { socket.close() } catch {} + return + } + selected = true + clearTimeout(openTimer) + for (const other of attempts) { + if (other.socket !== socket) { + try { other.socket.close() } catch {} + } + } + attachHubSocket(socket, hubUrl) + } + socket.onerror = (event) => { + if (!selected) { + const message = String(event?.message || 'websocket_error') + hubState.lastError = message + console.error(`[ai-workspace-bridge] hub error: ${message} (${hubUrl})`) + } + } + socket.onclose = () => { + attempt.closed = true + if (selected || reconnectScheduled) return + if (attempts.length && attempts.every((item) => item.closed)) { + clearTimeout(openTimer) + scheduleReconnect(1000, '[ai-workspace-bridge] all hub candidates closed before ready; retrying.') + } + } + } + + if (!attempts.length) { + clearTimeout(openTimer) + scheduleReconnect(5000, '[ai-workspace-bridge] no valid hub candidates; retrying.') + } +} + +async function handle(req, res) { + const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`) + if (req.method === 'OPTIONS') return sendJson(res, 204, {}) + + if (req.method === 'GET' && url.pathname === '/api/ai-workspace/bridge/v1/health') { + return sendJson(res, 200, await handleBridgeCommand('health')) + } + + if (req.method === 'GET' && url.pathname === '/api/ai-workspace/bridge/v1/conversations') { + return sendJson(res, 200, await handleBridgeCommand('conversations')) + } + + if (req.method === 'GET' && url.pathname === '/api/ai-workspace/bridge/v1/workspaces') { + return sendJson(res, 200, await handleBridgeCommand('workspaces')) + } + + if (req.method === 'POST' && url.pathname === '/api/ai-workspace/bridge/v1/message') { + const payload = await readBody(req) + try { + return sendJson(res, 200, await handleBridgeCommand('message', payload, () => {}, { requestId: `local-${Date.now()}` })) + } catch (error) { + return sendJson(res, Number(error?.status || 502), { ok: false, error: String(error?.message || error || 'codex_failed') }) + } + } + + if (req.method === 'POST' && url.pathname === '/api/ai-workspace/bridge/v1/stop') { + const payload = await readBody(req) + return sendJson(res, 200, await handleBridgeCommand('stop', payload)) + } + + return sendJson(res, 404, { ok: false, error: 'not_found' }) +} + +const server = http.createServer((req, res) => { + handle(req, res).catch((error) => { + sendJson(res, 500, { ok: false, error: String(error?.message || error || 'worker_failed') }) + }) +}) + +server.listen(PORT, HOST, () => { + console.log(`[ai-workspace-bridge] http://${HOST}:${PORT}`) + console.log(`[ai-workspace-bridge] codex: ${CODEX_BIN} ${CODEX_ARGS.join(' ')}`) + console.log(`[ai-workspace-bridge] codex home: ${CODEX_HOME}`) + console.log(`[ai-workspace-bridge] cwd: ${CODEX_CWD}`) + if (HUB_URLS.length && PAIRING_CODE) console.log(`[ai-workspace-bridge] hub candidates: ${HUB_URLS.join(', ')}`) +}) + +connectHub() +'@ + + $ndcAgentMcpServerCode = @' +#!/usr/bin/env node +import fs from 'node:fs/promises' +import path from 'node:path' + +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 context = parseContext() +const nodeCatalogCache = new Map() +let transportMode = null +let inputBuffer = Buffer.alloc(0) + +const FALLBACK_NODE_CATALOG = [ + { + name: 'webhook', + displayName: 'Webhook', + description: 'Starts a workflow from an HTTP webhook request.', + group: ['trigger'], + version: [2.1], + defaults: { name: 'Webhook' }, + inputs: [], + outputs: ['main'], + properties: [ + { name: 'httpMethod', displayName: 'HTTP Method', type: 'options', default: 'POST' }, + { name: 'path', displayName: 'Path', type: 'string', default: '' }, + { name: 'responseMode', displayName: 'Response Mode', type: 'options', default: 'onReceived' }, + ], + }, + { + name: 'set', + displayName: 'Set', + description: 'Sets or edits item fields.', + group: ['transform'], + version: [3.4], + defaults: { name: 'Set' }, + inputs: ['main'], + outputs: ['main'], + properties: [ + { name: 'assignments', displayName: 'Assignments', type: 'fixedCollection', default: {} }, + { name: 'options', displayName: 'Options', type: 'collection', default: {} }, + ], + }, + { + name: 'code', + displayName: 'Code', + description: 'Runs custom JavaScript code.', + group: ['transform'], + version: [2], + defaults: { name: 'Code' }, + inputs: ['main'], + outputs: ['main'], + properties: [ + { name: 'mode', displayName: 'Mode', type: 'options', default: 'runOnceForAllItems' }, + { name: 'jsCode', displayName: 'JavaScript Code', type: 'string', default: 'return items;' }, + ], + }, + { + name: 'httpRequest', + displayName: 'HTTP Request', + description: 'Makes an HTTP request and returns the response.', + group: ['input'], + version: [4.2], + defaults: { name: 'HTTP Request' }, + inputs: ['main'], + outputs: ['main'], + properties: [ + { name: 'method', displayName: 'Method', type: 'options', default: 'GET' }, + { name: 'url', displayName: 'URL', type: 'string', default: '' }, + { name: 'sendHeaders', displayName: 'Send Headers', type: 'boolean', default: false }, + { name: 'sendBody', displayName: 'Send Body', type: 'boolean', default: false }, + ], + }, + { + name: 'respondToWebhook', + displayName: 'Respond to Webhook', + description: 'Returns a response to a Webhook trigger.', + group: ['output'], + version: [1.1], + defaults: { name: 'Respond to Webhook' }, + inputs: ['main'], + outputs: ['main'], + properties: [ + { name: 'respondWith', displayName: 'Respond With', type: 'options', default: 'json' }, + { name: 'responseBody', displayName: 'Response Body', type: 'json', default: '{}' }, + ], + }, + { + name: 'noOp', + displayName: 'No Operation, do nothing', + description: 'Passes data through without changing it.', + group: ['transform'], + version: [1], + defaults: { name: 'No Operation, do nothing' }, + inputs: ['main'], + outputs: ['main'], + properties: [], + }, +] + +function parseContext() { + const raw = String(process.env.NDC_AGENT_MCP_CONTEXT || '').trim() + let parsed = {} + if (raw) { + try { parsed = JSON.parse(raw) || {} } catch {} + } + return { + 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 || ''), + agentNodeTitle: cleanString(parsed.agentNodeTitle || process.env.NDC_AGENT_MCP_NODE_TITLE || ''), + roleId: cleanString(parsed.roleId || process.env.NDC_AGENT_MCP_ROLE_ID || ''), + roleTitle: cleanString(parsed.roleTitle || process.env.NDC_AGENT_MCP_ROLE_TITLE || ''), + 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')), + } +} + +function cleanString(value, max = 1000) { + return String(value || '').trim().slice(0, max) +} + +function cleanApiBase(value) { + const raw = cleanString(value || DEFAULT_API_BASE) + return raw.replace(/\/+$/, '') || DEFAULT_API_BASE +} + +function uniqueStrings(values) { + const result = [] + const seen = new Set() + for (const value of values || []) { + const clean = cleanString(value, 1000).replace(/\/+$/, '') + if (!clean || seen.has(clean)) continue + seen.add(clean) + result.push(clean) + } + return result +} + +function isLoopbackUrl(value) { + try { + const host = new URL(value).hostname.toLowerCase() + return host === 'localhost' || host === '0.0.0.0' || host === '::1' || host.startsWith('127.') + } catch { + return false + } +} + +function hubOriginToApiBase(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(/\/+$/, '') + if (host === 'ai-hub.nodedc.ru') { + const pairingCode = cleanPairingCode(process.env.AI_BRIDGE_PAIRING_CODE) + return pairingCode ? `${origin}/api/ai-workspace/hub/v1/ndc-agent-mcp/${pairingCode}` : '' + } + return `${origin}/api/ndc-agent-mcp` + } catch { + return '' + } +} + +function cleanPairingCode(value) { + return cleanString(value, 100).toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 32) +} + +function splitList(value) { + return cleanString(value, 4000).split(/[,;\r\n]+/).map((item) => item.trim()).filter(Boolean) +} + +function engineApiBaseCandidates() { + const explicit = cleanApiBase(context.apiBaseUrl) + const hubBases = [ + hubOriginToApiBase(process.env.AI_BRIDGE_HUB_URL), + ...splitList(process.env.AI_BRIDGE_HUB_URLS).map(hubOriginToApiBase), + ] + const envBase = cleanString(process.env.NDC_AGENT_MCP_API_BASE_URL, 1000).replace(/\/+$/, '') + const ordered = isLoopbackUrl(explicit) && hubBases.some(Boolean) + ? [...hubBases, envBase, explicit, DEFAULT_API_BASE] + : [explicit, envBase, ...hubBases, DEFAULT_API_BASE] + return uniqueStrings(ordered) +} + +function engineApiUrls(pathname) { + const pathValue = String(pathname || '') + return engineApiBaseCandidates().map((baseValue) => { + try { + const base = new URL(baseValue) + if (pathValue.startsWith('/api/')) return `${base.origin}${pathValue}` + if (pathValue.startsWith('/')) return `${baseValue}${pathValue}` + return new URL(pathValue, `${baseValue}/`).toString() + } catch { + return '' + } + }).filter(Boolean) +} + +function fetchFailureText(error) { + const parts = [ + error?.name, + error?.message, + error?.cause?.code, + error?.cause?.message, + ].map((item) => cleanString(item, 300)).filter(Boolean) + return parts.join(':') || 'fetch_failed' +} + +function toolText(value) { + const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2) + return { content: [{ type: 'text', text }], structuredContent: typeof value === 'string' ? undefined : value } +} + +function jsonRpcResult(id, result) { + return { jsonrpc: '2.0', id, result } +} + +function jsonRpcError(id, code, message, data) { + return { jsonrpc: '2.0', id, error: { code, message: String(message || 'error'), ...(data ? { data } : {}) } } +} + +function writeMessage(message) { + const json = JSON.stringify(message) + if (transportMode === 'content-length') { + const body = Buffer.from(json, 'utf8') + process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`) + process.stdout.write(body) + return + } + process.stdout.write(`${json}\n`) +} + +async function readJson(file, fallback = null) { + try { return JSON.parse(await fs.readFile(file, 'utf8')) } catch { return fallback } +} + +async function loadNodeCatalog(version = context.schemaVersion) { + const key = cleanString(version || DEFAULT_SCHEMA_VERSION) + if (nodeCatalogCache.has(key)) return nodeCatalogCache.get(key) + const remoteCatalog = await loadRemoteNodeCatalog(key) + if (remoteCatalog.length) { + nodeCatalogCache.set(key, remoteCatalog) + return remoteCatalog + } + const direct = path.resolve(ROOT, 'server', 'assets', 'n8n', 'schema', key, 'nodes.catalog.json') + const fallback = path.resolve(ROOT, 'server', 'assets', 'n8n', 'schema', DEFAULT_SCHEMA_VERSION, 'nodes.catalog.json') + const raw = await readJson(direct, null) || await readJson(fallback, []) + const catalog = Array.isArray(raw) && raw.length ? raw : FALLBACK_NODE_CATALOG + nodeCatalogCache.set(key, catalog) + return catalog +} + +async function loadN8nMcpVersion() { + const pkg = await readJson(path.join(context.n8nMcpRefPath, 'package.json'), null) + return pkg?.version ? String(pkg.version) : '2.33.2' +} + +function engineApiUrl(pathname) { + return engineApiUrls(pathname)[0] || '' +} + +async function loadRemoteNodeCatalog(version) { + const query = new URLSearchParams({ kind: 'nodes', version: version || DEFAULT_SCHEMA_VERSION }) + const urls = engineApiUrls(`/api/n8n/schema?${query.toString()}`) + for (const url of urls) { + try { + const res = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(ENGINE_FETCH_TIMEOUT_MS) }) + if (!res.ok) continue + const json = await res.json() + if (Array.isArray(json?.nodes)) return json.nodes + } catch {} + } + return [] +} + +function normalizeTypeName(value) { + const raw = cleanString(value, 240) + if (!raw) return '' + if (raw.startsWith('n8n-nodes-base.')) return raw.slice('n8n-nodes-base.'.length) + return raw +} + +function fullNodeType(value) { + const raw = cleanString(value, 240) + if (!raw) return '' + return raw.startsWith('n8n-nodes-base.') ? raw : `n8n-nodes-base.${raw}` +} + +function cleanWebhookSegment(value, fallback) { + const out = cleanString(value, 240) + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + return out || fallback +} + +function makeNdcWebhookPath(workflowId, nodeId) { + return `ndc/${cleanWebhookSegment(workflowId, 'workflow')}/${cleanWebhookSegment(nodeId, 'agent')}/run` +} + +function defaultSubworkflowGraph(workflowId = context.workflowId, nodeId = context.agentNodeId) { + return { + version: 1, + nodes: [{ + id: 'webhook-trigger', + type: 'n8nOp', + position: { x: 0, y: 0 }, + data: { + title: 'Webhook Trigger', + n8n: { + parameters: { + httpMethod: 'POST', + path: makeNdcWebhookPath(workflowId, nodeId), + responseMode: 'onReceived', + options: {}, + }, + type: 'n8n-nodes-base.webhook', + typeVersion: 2.1, + position: [0, 0], + id: 'webhook-trigger', + name: 'Webhook Trigger', + }, + w: 220, + h: 120, + n8nTypeLabel: 'Webhook', + n8nShowId: false, + n8nOutputLabels: [], + }, + }], + edges: [], + source: { + createdBy: 'ndc-agent-mcp', + initialized: 'default-webhook', + }, + } +} + +function normalizeGraphResult(value, workflowId = context.workflowId, nodeId = context.agentNodeId) { + const graph = value && typeof value === 'object' ? { ...value } : defaultSubworkflowGraph(workflowId, nodeId) + graph.version = Number(graph.version || 1) || 1 + graph.nodes = Array.isArray(graph.nodes) && graph.nodes.length ? graph.nodes : defaultSubworkflowGraph(workflowId, nodeId).nodes + graph.edges = Array.isArray(graph.edges) ? graph.edges : [] + return graph +} + +function versionSummary(version) { + if (Array.isArray(version)) return version + if (version === undefined || version === null) return [] + return [version] +} + +function compactNodeDefinition(node, maxProperties = 80) { + const properties = Array.isArray(node?.properties) ? node.properties.slice(0, maxProperties) : [] + return { + name: node?.name || '', + fullType: fullNodeType(node?.name || ''), + displayName: node?.displayName || node?.name || '', + description: node?.description || '', + group: node?.group || [], + versions: versionSummary(node?.version), + defaults: node?.defaults || {}, + inputs: node?.inputs || [], + outputs: node?.outputs || [], + credentials: node?.credentials || [], + properties, + propertiesTruncated: Array.isArray(node?.properties) && node.properties.length > properties.length, + } +} + +async function engineFetch(pathname, options = {}) { + const urls = engineApiUrls(pathname) + const failures = [] + for (const url of urls) { + let res = null + try { + res = await fetch(url, { + ...options, + redirect: 'manual', + signal: options.signal || AbortSignal.timeout(ENGINE_FETCH_TIMEOUT_MS), + headers: { + Accept: 'application/json', + ...(options.body ? { 'Content-Type': 'application/json' } : {}), + ...(options.headers || {}), + }, + }) + } catch (error) { + failures.push(`${url} -> ${fetchFailureText(error)}`) + continue + } + const text = await res.text().catch(() => '') + let json = null + try { json = text ? JSON.parse(text) : null } catch {} + if (!res.ok || json?.ok === false) { + const error = new Error(json?.message || json?.error || `engine_http_${res.status}`) + error.status = res.status + error.payload = { ...(json && typeof json === 'object' ? json : {}), url } + throw error + } + if (!json || typeof json !== 'object') { + const error = new Error(`engine_non_json_response:${res.status}`) + error.status = 502 + error.payload = { + url, + status: res.status, + contentType: res.headers.get('content-type') || '', + preview: cleanString(text, 200), + } + throw error + } + return json + } + const error = new Error(`engine_fetch_failed:${failures.join(' | ') || 'no_engine_api_urls'}`) + error.payload = { attempts: failures, apiBaseCandidates: engineApiBaseCandidates(), pathname } + throw error +} + +function targetFromArgs(args = {}) { + const workflowId = cleanString(args.workflowId || context.workflowId) + const nodeId = cleanString(args.nodeId || args.agentNodeId || context.agentNodeId) + if (!workflowId || !nodeId) throw new Error('workflowId_and_nodeId_required') + return { workflowId, nodeId } +} + +async function handleGetContext() { + const n8nMcpVersion = await loadN8nMcpVersion() + return { + ok: true, + ...context, + apiBaseCandidates: engineApiBaseCandidates(), + n8nMcpVersion, + contract: { + sourceOfTruth: 'Engine second-level dc.subworkflow.json', + writeApi: `${context.apiBaseUrl}/subworkflow/patch`, + schemaApi: engineApiUrl(`/api/n8n/schema?kind=nodes&version=${encodeURIComponent(context.schemaVersion)}`), + directCoreWritesAllowed: false, + directFileWritesAllowed: false, + }, + } +} + +async function handleGetSubworkflow(args) { + const target = targetFromArgs(args) + const q = new URLSearchParams(target) + try { + const result = await engineFetch(`/subworkflow?${q.toString()}`) + return { + ok: true, + ...(result && typeof result === 'object' ? result : {}), + graph: normalizeGraphResult(result?.graph, target.workflowId, target.nodeId), + } + } catch (error) { + if (Number(error?.status || 0) !== 404 && !/not_found/.test(String(error?.message || ''))) throw error + return { + ok: true, + graph: normalizeGraphResult(null, target.workflowId, target.nodeId), + missing: true, + } + } +} + +async function handleApplyPatch(args) { + const target = targetFromArgs(args) + const operations = Array.isArray(args.operations) ? args.operations : [] + if (!operations.length) throw new Error('operations_required') + const result = await engineFetch('/subworkflow/patch', { + method: 'POST', + body: JSON.stringify({ + workflowId: target.workflowId, + nodeId: target.nodeId, + intent: cleanString(args.intent || 'NDC Agent MCP patch', 500), + expectedUpdatedAt: cleanString(args.expectedUpdatedAt || ''), + operations, + }), + }) + if (result?.graph) { + return { ...result, graph: normalizeGraphResult(result.graph, target.workflowId, target.nodeId) } + } + const loaded = await handleGetSubworkflow(target) + return { + ok: true, + ...(result && typeof result === 'object' ? result : {}), + graph: loaded.graph, + recoveredAfterEmptyPatchResponse: !result?.graph, + } +} + +async function handleSearchNodes(args) { + const query = cleanString(args.query || args.q || '', 160).toLowerCase() + const limit = Math.min(Math.max(Number(args.limit || 12) || 12, 1), 50) + const catalog = await loadNodeCatalog(args.schemaVersion) + const terms = query.split(/\s+/).filter(Boolean) + const matches = catalog + .map((node) => { + const name = String(node?.name || '').toLowerCase() + const displayName = String(node?.displayName || '').toLowerCase() + const description = String(node?.description || '').toLowerCase() + const groups = (Array.isArray(node?.group) ? node.group : []).map((item) => String(item || '').toLowerCase()) + const score = !terms.length ? 1 : terms.reduce((acc, term) => { + if (name === term) return acc + 100 + if (displayName === term) return acc + 90 + if (name.includes(term)) return acc + 25 + if (displayName.includes(term)) return acc + 20 + if (groups.some((group) => group.includes(term))) return acc + 6 + if (description.includes(term)) return acc + 2 + return acc + }, 0) + return { node, score } + }) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score || String(a.node?.displayName || '').localeCompare(String(b.node?.displayName || ''))) + .slice(0, limit) + .map(({ node }) => ({ + name: node?.name || '', + fullType: fullNodeType(node?.name || ''), + displayName: node?.displayName || node?.name || '', + description: node?.description || '', + group: node?.group || [], + versions: versionSummary(node?.version), + })) + return { ok: true, query, count: matches.length, nodes: matches } +} + +async function handleGetNodeDefinition(args) { + const nodeType = normalizeTypeName(args.nodeType || args.type || args.name) + if (!nodeType) throw new Error('nodeType_required') + const catalog = await loadNodeCatalog(args.schemaVersion) + const node = catalog.find((item) => ( + String(item?.name || '') === nodeType || + fullNodeType(item?.name || '') === cleanString(args.nodeType || args.type || args.name) + )) + if (!node) throw new Error(`node_not_found:${nodeType}`) + return { ok: true, node: compactNodeDefinition(node, Number(args.maxProperties || 80) || 80) } +} + +async function handleValidateSubworkflow(args) { + const target = (() => { + try { return targetFromArgs(args) } catch { return { workflowId: context.workflowId, nodeId: context.agentNodeId } } + })() + const graph = args.graph && typeof args.graph === 'object' + ? args.graph + : normalizeGraphResult((await handleGetSubworkflow(args)).graph, target.workflowId, target.nodeId) + const nodes = Array.isArray(graph?.nodes) ? graph.nodes : [] + const edges = Array.isArray(graph?.edges) ? graph.edges : [] + const nodeIds = new Set() + const issues = [] + nodes.forEach((node, index) => { + const id = cleanString(node?.id) + if (!id) issues.push({ level: 'error', code: 'node_id_missing', index }) + if (id && nodeIds.has(id)) issues.push({ level: 'error', code: 'node_id_duplicate', nodeId: id }) + if (id) nodeIds.add(id) + const typeName = cleanString(node?.data?.n8n?.type) + if (!typeName) issues.push({ level: 'warn', code: 'node_type_missing', nodeId: id }) + }) + edges.forEach((edge, index) => { + const source = cleanString(edge?.source) + const target = cleanString(edge?.target) + if (!source || !target) issues.push({ level: 'error', code: 'edge_endpoint_missing', index }) + if (source && !nodeIds.has(source)) issues.push({ level: 'error', code: 'edge_source_missing', edgeId: edge?.id, source }) + if (target && !nodeIds.has(target)) issues.push({ level: 'error', code: 'edge_target_missing', edgeId: edge?.id, target }) + }) + return { + ok: !issues.some((issue) => issue.level === 'error'), + nodesCount: nodes.length, + edgesCount: edges.length, + issues, + } +} + +const tools = [ + { + name: 'ndc_get_context', + description: 'Return selected NDC Agent Core target context and safety contract.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + { + name: 'ndc_get_subworkflow', + description: 'Read the selected Engine second-level workflow graph from dc.subworkflow.json.', + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string' }, + nodeId: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + { + name: 'ndc_apply_subworkflow_patch', + description: 'Apply safe graph patch operations to the selected Engine second-level workflow.', + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string' }, + nodeId: { type: 'string' }, + intent: { type: 'string' }, + expectedUpdatedAt: { type: 'string' }, + operations: { + type: 'array', + items: { type: 'object', additionalProperties: true }, + }, + }, + required: ['operations'], + additionalProperties: false, + }, + }, + { + name: 'ndc_search_nodes', + description: 'Search NDC node definitions from the pinned v2.3.2 schema catalog.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string' }, + limit: { type: 'number' }, + schemaVersion: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + { + name: 'ndc_get_node_definition', + description: 'Return a compact NDC node definition, including properties, credentials, inputs, and outputs.', + inputSchema: { + type: 'object', + properties: { + nodeType: { type: 'string' }, + schemaVersion: { type: 'string' }, + maxProperties: { type: 'number' }, + }, + required: ['nodeType'], + additionalProperties: false, + }, + }, + { + name: 'ndc_validate_subworkflow', + description: 'Validate a second-level workflow graph or the selected saved graph for basic structural issues.', + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string' }, + nodeId: { type: 'string' }, + graph: { type: 'object', additionalProperties: true }, + }, + additionalProperties: false, + }, + }, +] + +async function callTool(name, args) { + if (name === 'ndc_get_context') return handleGetContext() + if (name === 'ndc_get_subworkflow') return handleGetSubworkflow(args) + if (name === 'ndc_apply_subworkflow_patch') return handleApplyPatch(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) + throw new Error(`unknown_tool:${name}`) +} + +async function handleRequest(message) { + const id = message?.id + const method = String(message?.method || '') + if (!method) return + if (method.startsWith('notifications/')) return + + try { + if (method === 'initialize') { + return writeMessage(jsonRpcResult(id, { + protocolVersion: message?.params?.protocolVersion || '2025-06-18', + capabilities: { tools: {} }, + serverInfo: { name: 'ndc-agent-mcp', version: '0.1.0' }, + })) + } + if (method === 'ping') return writeMessage(jsonRpcResult(id, {})) + if (method === 'tools/list') return writeMessage(jsonRpcResult(id, { tools })) + if (method === 'tools/call') { + const name = cleanString(message?.params?.name) + const args = message?.params?.arguments && typeof message.params.arguments === 'object' + ? message.params.arguments + : {} + const result = await callTool(name, args) + return writeMessage(jsonRpcResult(id, toolText(result))) + } + if (method === 'resources/list') return writeMessage(jsonRpcResult(id, { resources: [] })) + if (method === 'prompts/list') return writeMessage(jsonRpcResult(id, { prompts: [] })) + return writeMessage(jsonRpcError(id, -32601, `method_not_found:${method}`)) + } catch (error) { + return writeMessage(jsonRpcError(id, -32000, error?.message || error, error?.payload)) + } +} + +function parseJsonMessage(raw) { + try { + return JSON.parse(raw) + } catch { + writeMessage(jsonRpcError(null, -32700, 'parse_error')) + return null + } +} + +function consumeJsonLineMessages() { + for (;;) { + const newlineIndex = inputBuffer.indexOf(0x0a) + if (newlineIndex < 0) return + const line = inputBuffer.subarray(0, newlineIndex).toString('utf8') + inputBuffer = inputBuffer.subarray(newlineIndex + 1) + const trimmed = line.trim() + if (!trimmed) continue + const message = parseJsonMessage(trimmed) + if (message) void handleRequest(message) + } +} + +function consumeContentLengthMessages() { + for (;;) { + const headerEnd = inputBuffer.indexOf('\r\n\r\n') + const altHeaderEnd = headerEnd < 0 ? inputBuffer.indexOf('\n\n') : -1 + const boundary = headerEnd >= 0 ? headerEnd : altHeaderEnd + if (boundary < 0) return + + const separatorLength = headerEnd >= 0 ? 4 : 2 + const header = inputBuffer.subarray(0, boundary).toString('utf8') + const match = /content-length:\s*(\d+)/i.exec(header) + if (!match) { + inputBuffer = inputBuffer.subarray(boundary + separatorLength) + writeMessage(jsonRpcError(null, -32600, 'content_length_missing')) + continue + } + + const contentLength = Number(match[1]) + const bodyStart = boundary + separatorLength + const bodyEnd = bodyStart + contentLength + if (inputBuffer.length < bodyEnd) return + + const body = inputBuffer.subarray(bodyStart, bodyEnd).toString('utf8') + inputBuffer = inputBuffer.subarray(bodyEnd) + const message = parseJsonMessage(body) + if (message) void handleRequest(message) + } +} + +function consumeInputBuffer() { + if (!inputBuffer.length) return + + if (!transportMode) { + const trimmedStart = inputBuffer.toString('utf8', 0, Math.min(inputBuffer.length, 32)).trimStart() + if (trimmedStart.startsWith('Content-Length:') || trimmedStart.toLowerCase().startsWith('content-length:')) { + transportMode = 'content-length' + } else if (trimmedStart.startsWith('{')) { + transportMode = 'json-lines' + } else if (inputBuffer.length < 32) { + return + } else { + transportMode = 'json-lines' + } + } + + if (transportMode === 'content-length') consumeContentLengthMessages() + else consumeJsonLineMessages() +} + +process.stdin.on('data', (chunk) => { + inputBuffer = Buffer.concat([inputBuffer, Buffer.from(chunk)]) + consumeInputBuffer() +}) + +process.stdin.on('end', () => { + if (transportMode === 'json-lines') { + const line = inputBuffer.toString('utf8').trim() + inputBuffer = Buffer.alloc(0) + if (!line) return + const message = parseJsonMessage(line) + if (message) void handleRequest(message) + } +}) + +if (process.env.NDC_AGENT_MCP_DEBUG_JSONL === '1') { + process.stdin.setEncoding('utf8') +} + +process.on('uncaughtException', (error) => { + writeMessage(jsonRpcError(null, -32000, error?.message || error)) +}) + +process.on('unhandledRejection', (reason) => { + writeMessage(jsonRpcError(null, -32000, reason?.message || reason)) +}) + +if (process.stdin.isTTY) { + process.stderr.write('ndc-agent-mcp expects JSON-RPC messages on stdin.\n') +} +'@ + + Set-Content -Path $workerPath -Value $workerCode -Encoding UTF8 + Set-Content -Path $ndcAgentMcpServerPath -Value $ndcAgentMcpServerCode -Encoding UTF8 + + $startCode = @" +`$ErrorActionPreference = 'Stop' +`$env:AI_BRIDGE_HOST = '0.0.0.0' +`$env:AI_BRIDGE_PORT = '$Port' +`$env:AI_BRIDGE_CODEX_CWD = $(ConvertTo-PsSingleQuoted $WorkspacePath) +`$env:AI_BRIDGE_CODEX_BIN = 'codex' +`$env:AI_BRIDGE_CODEX_ARGS = '["exec","--json","--skip-git-repo-check","-c","model_reasoning_summary=detailed","-c","model_supports_reasoning_summaries=true","-c","use_experimental_reasoning_summary=true","-c","hide_agent_reasoning=false","-c","show_raw_agent_reasoning=false","-"]' +`$env:AI_BRIDGE_ALLOW_ORIGIN = '*' +`$env:AI_BRIDGE_HUB_URL = $(ConvertTo-PsSingleQuoted $HubUrl) +`$env:AI_BRIDGE_HUB_URLS = $(ConvertTo-PsSingleQuoted $hubUrlsText) +`$env:AI_BRIDGE_PAIRING_CODE = $(ConvertTo-PsSingleQuoted $PairingCode) +`$env:AI_BRIDGE_MACHINE_NAME = $(ConvertTo-PsSingleQuoted $resolvedMachineName) +`$env:AI_BRIDGE_ACTIVE_MIRROR_DIR = $(ConvertTo-PsSingleQuoted $activeMirror) +`$env:AI_BRIDGE_ACTIVE_MIRROR_OPEN_IDE = '1' +`$env:Path = $(ConvertTo-PsSingleQuoted $nodeDir) + ';' + $(ConvertTo-PsSingleQuoted $npmPrefix) + ';' + `$env:Path +Set-Location $(ConvertTo-PsSingleQuoted $Root) +& $(ConvertTo-PsSingleQuoted $nodeSource) $(ConvertTo-PsSingleQuoted $workerPath) *>> $(ConvertTo-PsSingleQuoted $logPath) +"@ + Set-Content -Path $startScript -Value $startCode -Encoding UTF8 + + $watchdogCode = @" +`$ErrorActionPreference = 'SilentlyContinue' +`$startScript = $(ConvertTo-PsSingleQuoted $startScript) +`$logPath = $(ConvertTo-PsSingleQuoted $logPath) +`$healthUrl = $(ConvertTo-PsSingleQuoted "http://127.0.0.1:$Port$HealthPath") +`$healthy = `$false +try { + `$request = [System.Net.HttpWebRequest]::Create(`$healthUrl) + `$request.Method = 'GET' + `$request.Proxy = `$null + `$request.Timeout = 2000 + `$request.ReadWriteTimeout = 2000 + `$response = `$request.GetResponse() + try { + `$stream = `$response.GetResponseStream() + `$reader = New-Object System.IO.StreamReader(`$stream) + `$body = `$reader.ReadToEnd() + `$healthy = `$response.StatusCode -eq 200 -and `$body -match '"ok"\s*:\s*true' + } finally { + `$response.Close() + } +} catch {} +if (-not `$healthy) { + try { + Add-Content -Path `$logPath -Value ("[{0}] watchdog: health probe failed; starting bridge." -f (Get-Date).ToString('s')) -Encoding UTF8 + } catch {} + Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile', '-WindowStyle', 'Hidden', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', `$startScript) -WindowStyle Hidden | Out-Null +} +"@ + Set-Content -Path $watchdogScript -Value $watchdogCode -Encoding UTF8 + + $config = [ordered]@{ + service = 'NDC AI Workspace Bridge' + port = $Port + installRoot = $Root + workspace = $WorkspacePath + codexHome = $bridgeCodexHome + worker = $workerPath + startScript = $startScript + watchdogScript = $watchdogScript + watchdogEnabled = [bool]$EnableWatchdog + taskName = $TaskName + watchdogTaskName = "$TaskName Watchdog" + healthPath = $HealthPath + conversationPath = $ConversationPath + activeMirror = $activeMirror + } + $config | ConvertTo-Json -Depth 5 | Set-Content -Path $configPath -Encoding UTF8 + Write-Ok "Installed to $Root" + return @{ + WorkerPath = $workerPath + StartScript = $startScript + WatchdogScript = $watchdogScript + ConfigPath = $configPath + Logs = $logs + } +} + +function Register-BridgeAutostart { + param( + [string]$StartScript, + [string]$WatchdogScript + ) + $watchdogTaskName = "$TaskName Watchdog" + if (-not $EnableWatchdog) { + try { + Stop-ScheduledTask -TaskName $watchdogTaskName -ErrorAction SilentlyContinue + } catch {} + try { + Unregister-ScheduledTask -TaskName $watchdogTaskName -Confirm:$false -ErrorAction SilentlyContinue + Write-Ok "Watchdog task disabled: $watchdogTaskName" + } catch {} + } + + if ($NoAutostart) { + Write-Warn 'Autostart skipped by flag.' + return + } + + Write-Step 'Registering Windows autostart' + $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -WindowStyle Hidden -NonInteractive -ExecutionPolicy Bypass -File `"$StartScript`"" + $triggers = @() + $triggers += New-ScheduledTaskTrigger -AtLogOn + $triggers += New-ScheduledTaskTrigger -AtStartup + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Days 3650) -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -MultipleInstances IgnoreNew + + Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $triggers -Settings $settings -Description 'NDC AI Workspace Bridge for NODE.DC AI Workspace' -RunLevel Highest -Force | Out-Null + Write-Ok "Scheduled task registered: $TaskName" + + if ($EnableWatchdog -and $WatchdogScript) { + $watchdogAction = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -WindowStyle Hidden -NonInteractive -ExecutionPolicy Bypass -File `"$WatchdogScript`"" + $watchdogTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(2) -RepetitionInterval (New-TimeSpan -Minutes 5) -RepetitionDuration (New-TimeSpan -Days 3650) + $watchdogSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5) -MultipleInstances IgnoreNew + Register-ScheduledTask -TaskName $watchdogTaskName -Action $watchdogAction -Trigger $watchdogTrigger -Settings $watchdogSettings -Description 'NDC AI Workspace Bridge watchdog' -RunLevel Highest -Force | Out-Null + Write-Ok "Watchdog task registered: $watchdogTaskName" + } +} + +function Start-BridgeNow { + param([string]$StartScript) + + Write-Step 'Starting bridge now' + Stop-BridgeNow + + Start-Process -FilePath 'powershell.exe' -ArgumentList "-NoProfile -WindowStyle Hidden -NonInteractive -ExecutionPolicy Bypass -File `"$StartScript`"" -WindowStyle Hidden | Out-Null + Write-Ok 'Bridge process started.' +} + +function Stop-BridgeNow { + try { + Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + } catch {} + try { + Stop-ScheduledTask -TaskName "$TaskName Watchdog" -ErrorAction SilentlyContinue + } catch {} + + try { + $escapedRoot = [Regex]::Escape($InstallRoot) + $processes = Get-CimInstance Win32_Process | + Where-Object { + $_.CommandLine -match 'worker\.mjs' -and + ($_.CommandLine -match $escapedRoot -or $_.CommandLine -match 'AIWorkspaceBridge') + } + foreach ($process in $processes) { + try { + Stop-Process -Id $process.ProcessId -Force -ErrorAction Stop + Write-Ok "Stopped existing bridge process: $($process.ProcessId)" + } catch {} + } + } catch {} +} + +function Get-BridgeLogTail { + $logPath = Join-Path $InstallRoot 'logs\bridge.log' + if (-not (Test-Path $logPath)) { return '' } + try { + return ((Get-Content -Path $logPath -Tail 80 -ErrorAction Stop) -join [Environment]::NewLine) + } catch { + return '' + } +} + +function Get-PortOwnerReport { + param([int]$LocalPort = $Port) + + try { + $listeners = Get-NetTCPConnection -LocalPort $LocalPort -State Listen -ErrorAction SilentlyContinue | + Sort-Object OwningProcess, LocalAddress -Unique + if (-not $listeners) { return '' } + + $lines = @() + foreach ($listener in $listeners) { + $processId = [int]$listener.OwningProcess + $process = Get-CimInstance Win32_Process -Filter "ProcessId=$processId" -ErrorAction SilentlyContinue + if ($process) { + $commandLine = ([string]$process.CommandLine).Trim() + if ($commandLine.Length -gt 500) { $commandLine = $commandLine.Substring(0, 500) + '...' } + $lines += ("{0}:{1} PID {2} {3} {4}" -f $listener.LocalAddress, $listener.LocalPort, $processId, $process.Name, $commandLine) + } else { + $lines += ("{0}:{1} PID {2}" -f $listener.LocalAddress, $listener.LocalPort, $processId) + } + } + return ($lines -join [Environment]::NewLine) + } catch { + return '' + } +} + +function Test-BridgeProcess { + param([object]$Process) + if (-not $Process) { return $false } + $commandLine = [string]$Process.CommandLine + if (-not $commandLine) { return $false } + try { + $escapedRoot = [Regex]::Escape($InstallRoot) + return ( + $commandLine -match 'worker\.mjs' -and + ($commandLine -match $escapedRoot -or $commandLine -match 'AIWorkspaceBridge') + ) + } catch { + return ($commandLine -match 'worker\.mjs' -and $commandLine -match 'AIWorkspaceBridge') + } +} + +function Get-BlockingPortOwners { + param([int]$LocalPort) + + try { + $listeners = Get-NetTCPConnection -LocalPort $LocalPort -State Listen -ErrorAction SilentlyContinue | + Sort-Object OwningProcess, LocalAddress -Unique + if (-not $listeners) { return @() } + + $blocking = @() + foreach ($listener in $listeners) { + $processId = [int]$listener.OwningProcess + $process = Get-CimInstance Win32_Process -Filter "ProcessId=$processId" -ErrorAction SilentlyContinue + if (Test-BridgeProcess $process) { continue } + $blocking += [pscustomobject]@{ + LocalAddress = $listener.LocalAddress + LocalPort = $listener.LocalPort + ProcessId = $processId + Name = if ($process) { $process.Name } else { '' } + CommandLine = if ($process) { [string]$process.CommandLine } else { '' } + } + } + return @($blocking) + } catch { + return @() + } +} + +function Resolve-BridgePort { + $requested = [int]$Port + for ($candidate = $requested; $candidate -le ($requested + 20); $candidate++) { + $blocking = @(Get-BlockingPortOwners -LocalPort $candidate) + if ($blocking.Count -eq 0) { + if ($candidate -ne $requested) { + Write-Warn "TCP $requested is occupied by another service; using TCP $candidate for local bridge health." + } + return $candidate + } + } + + $report = Get-PortOwnerReport -LocalPort $requested + if ($report) { + throw "No free bridge port near $requested. Current listener on TCP ${requested}:`n$report" + } + throw "No free bridge port near $requested." +} + +function Try-StartBridgeViaTask { + if ($NoAutostart) { return } + try { + Start-ScheduledTask -TaskName $TaskName + Write-Ok 'Autostart task started for validation.' + } catch { + Write-Warn "Could not start scheduled task: $($_.Exception.Message)" + } +} + +function Ensure-Firewall { + if ($NoFirewall) { + Write-Warn 'Firewall rule skipped by flag.' + return + } + + Write-Step 'Configuring Windows Firewall' + $displayName = "NDC AI Workspace Bridge $Port" + $existing = Get-NetFirewallRule -DisplayName $displayName -ErrorAction SilentlyContinue + if ($existing) { + $existing | Set-NetFirewallRule -Enabled True -Profile Domain,Private,Public -Direction Inbound -Action Allow + Write-Ok "Firewall rule updated for TCP ${Port}: $displayName" + return + } + + New-NetFirewallRule -DisplayName $displayName -Direction Inbound -Action Allow -Protocol TCP -LocalPort $Port -Profile Domain,Private,Public | Out-Null + Write-Ok "Firewall rule added for TCP $Port" +} + +function Normalize-PairingCode { + param([string]$Value) + if (-not $Value) { return '' } + return (($Value -replace '[^A-Za-z0-9]', '').ToUpperInvariant()) +} + +function Get-LocalBridgeHealth { + $healthUrl = "http://127.0.0.1:$Port$HealthPath" + try { + $request = [System.Net.HttpWebRequest]::Create($healthUrl) + $request.Method = 'GET' + $request.Proxy = $null + $request.Timeout = 2000 + $request.ReadWriteTimeout = 2000 + $response = $request.GetResponse() + try { + $stream = $response.GetResponseStream() + $reader = New-Object System.IO.StreamReader($stream) + $body = $reader.ReadToEnd() + $data = $null + try { $data = $body | ConvertFrom-Json } catch {} + return [pscustomobject]@{ + Ok = ($response.StatusCode -eq 200 -and $null -ne $data -and $data.ok -eq $true) + Url = $healthUrl + StatusCode = [int]$response.StatusCode + Body = $body + Data = $data + Error = '' + } + } finally { + $response.Close() + } + } catch { + $statusCode = 0 + $body = '' + $response = $_.Exception.Response + if ($response) { + try { + $statusCode = [int]$response.StatusCode + $stream = $response.GetResponseStream() + if ($stream) { + $reader = New-Object System.IO.StreamReader($stream) + $body = $reader.ReadToEnd() + } + } catch {} + try { $response.Close() } catch {} + } + return [pscustomobject]@{ + Ok = $false + Url = $healthUrl + StatusCode = $statusCode + Body = $body + Data = $null + Error = $_.Exception.Message + } + } +} + +function Wait-Health { + Write-Step 'Checking bridge health' + $lastError = '' + $lastStatusCode = 0 + $lastBody = '' + for ($i = 0; $i -lt 30; $i++) { + $health = Get-LocalBridgeHealth + if ($health.Ok) { + Write-Ok "Health OK: $($health.Url)" + return $health.Data + } + $lastError = $health.Error + $lastStatusCode = [int]$health.StatusCode + $lastBody = [string]$health.Body + Start-Sleep -Seconds 1 + } + Write-Warn "Bridge did not answer with JSON health yet: http://127.0.0.1:$Port$HealthPath" + if ($lastStatusCode -gt 0) { Write-Warn "Last health HTTP status: $lastStatusCode" } + if ($lastError) { Write-Warn "Last health error: $lastError" } + if ($lastBody) { + $bodyPreview = ($lastBody -replace '\s+', ' ').Trim() + if ($bodyPreview.Length -gt 500) { $bodyPreview = $bodyPreview.Substring(0, 500) + '...' } + Write-Warn "Last health body: $bodyPreview" + } + $portOwner = Get-PortOwnerReport + if ($portOwner) { + Write-Host "Current listener on TCP ${Port}:" -ForegroundColor Yellow + Write-Host $portOwner -ForegroundColor Yellow + } else { + Write-Warn "No listener detected on TCP $Port." + } + return $null +} + +function Wait-HubConnection { + param([object]$InitialHealth) + + if (-not $HubUrl -or -not $PairingCode) { + return $true + } + + Write-Step 'Checking Cloud Hub connection' + $expectedPairing = Normalize-PairingCode $PairingCode + $lastHubError = '' + $lastHubUrl = '' + for ($i = 0; $i -lt 60; $i++) { + $health = if ($i -eq 0 -and $InitialHealth) { $InitialHealth } else { (Get-LocalBridgeHealth).Data } + if ($health) { + $hub = $health.hub + $hubConnected = ($health.hubConnected -eq $true) + if ($hub) { + $hubConnected = $hubConnected -or ($hub.connected -eq $true) + $lastHubError = [string]$hub.lastError + $lastHubUrl = [string]$hub.url + $actualPairing = Normalize-PairingCode ([string]$hub.pairingCode) + if ($hubConnected -and $actualPairing -eq $expectedPairing) { + if ($lastHubUrl) { + Write-Ok "Cloud Hub connected: $lastHubUrl" + } else { + Write-Ok 'Cloud Hub connected.' + } + return $true + } + } + } + Start-Sleep -Seconds 1 + } + + Write-Warn 'Cloud Hub connection was not confirmed.' + if ($lastHubError) { Write-Warn "Last Hub error: $lastHubError" } + $hubUrlCandidates = @(Get-HubUrlCandidates) + if ($hubUrlCandidates.Count -gt 0) { + Write-Host "Hub candidates:" -ForegroundColor Yellow + foreach ($candidate in $hubUrlCandidates) { + Write-Host " $candidate" -ForegroundColor Yellow + } + } + return $false +} + +function Get-BridgeIps { + $ips = @() + try { + $ips = Get-NetIPAddress -AddressFamily IPv4 | + Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' } | + Sort-Object InterfaceMetric | + Select-Object -ExpandProperty IPAddress -Unique + } catch {} + + if (-not $ips -or $ips.Count -eq 0) { + $ips = [System.Net.Dns]::GetHostAddresses($env:COMPUTERNAME) | + Where-Object { $_.AddressFamily -eq 'InterNetwork' -and $_.IPAddressToString -notlike '127.*' } | + ForEach-Object { $_.IPAddressToString } + } + return @($ips) +} + +function Print-Result { + param( + [string]$Root, + [string]$WorkspacePath, + [bool]$Healthy, + [bool]$HubHealthy + ) + + $ips = Get-BridgeIps + $endpoint = if ($ips.Count -gt 0) { "http://$($ips[0]):$Port" } else { "http://:$Port" } + $hubUrlCandidates = @(Get-HubUrlCandidates) + + Write-Step 'Done' + Write-Host "AI Workspace settings:" + Write-Host " Type: Codex worker" + Write-Host " Model: codex" + if ($HubUrl -and $PairingCode) { + Write-Host " Connection: Cloud Hub" -ForegroundColor Green + if ($HubHealthy) { + Write-Host " Hub connection: confirmed" -ForegroundColor Green + } else { + Write-Host " Hub connection: not confirmed" -ForegroundColor Yellow + } + } else { + Write-Host " Bridge endpoint: $endpoint" -ForegroundColor Green + } + Write-Host "" + Write-Host "Local health:" + Write-Host " http://127.0.0.1:$Port$HealthPath" + Write-Host "" + Write-Host "Install root:" + Write-Host " $Root" + Write-Host "Workspace:" + Write-Host " $WorkspacePath" + Write-Host "Autostart task:" + Write-Host " $TaskName" + if ($hubUrlCandidates.Count -gt 0 -and $PairingCode) { + Write-Host "Hub candidates:" + foreach ($candidate in $hubUrlCandidates) { + Write-Host " $candidate" + } + Write-Host "Pairing code:" + Write-Host " $PairingCode" -ForegroundColor Green + } + + if ($ips.Count -gt 1) { + Write-Host "" + Write-Host "Other possible endpoints:" + foreach ($ip in $ips | Select-Object -Skip 1) { + Write-Host " http://${ip}:$Port" + } + } + + $copyText = @" +Type: Codex worker +Model: codex +Connection: $(if ($HubUrl -and $PairingCode) { 'Cloud Hub' } else { $endpoint }) +Hub connection: $(if ($HubUrl -and $PairingCode) { if ($HubHealthy) { 'confirmed' } else { 'not confirmed' } } else { 'not used' }) +Health: http://127.0.0.1:$Port$HealthPath +Workspace: $WorkspacePath +Hub candidates: $($hubUrlCandidates -join ', ') +Pairing code: $PairingCode +"@ + Write-InstallResult $copyText + try { + Set-Clipboard -Value $copyText + Write-Ok 'Connection details copied to clipboard.' + } catch {} + + if (-not $Healthy) { + Write-Warn 'If Engine cannot connect, check Windows Firewall profile and bridge.log in the install root.' + $logTail = Get-BridgeLogTail + if ($logTail) { + Write-Host "" + Write-Host "bridge.log tail:" -ForegroundColor Yellow + Write-Host $logTail + try { + Add-Content -Path (Get-ResultPath) -Value "`nbridge.log tail:`n$logTail" -Encoding UTF8 + } catch {} + } + } +} + +function Main { + if (-not $Confirmed) { + Write-Host "" + Write-Host "NDC AI Workspace Bridge will install or update Node.js, Codex CLI, a local bridge service, autostart, and firewall access." + Write-Host "Target port: $Port" + } + + Request-Elevation + $workspacePath = Get-WorkspacePath + Ensure-Node + Ensure-Codex + Ensure-CodexLogin + Repair-CodexMcpConfig + Stop-BridgeNow + $script:Port = Resolve-BridgePort + $files = Write-WorkerFiles -Root $InstallRoot -WorkspacePath $workspacePath + Ensure-Firewall + Register-BridgeAutostart -StartScript $files.StartScript -WatchdogScript $files.WatchdogScript + Start-BridgeNow -StartScript $files.StartScript + $health = Wait-Health + if (-not $health) { + Try-StartBridgeViaTask + $health = Wait-Health + } + $hubHealthy = $true + if ($health -and $HubUrl -and $PairingCode) { + $hubHealthy = Wait-HubConnection -InitialHealth $health + } elseif ($HubUrl -and $PairingCode) { + $hubHealthy = $false + } + Print-Result -Root $InstallRoot -WorkspacePath $workspacePath -Healthy ([bool]$health -and [bool]$hubHealthy) -HubHealthy ([bool]$hubHealthy) +} + +try { + Main +} catch { + $errorText = @" +NDC AI Workspace Bridge install failed. + +Error: +$($_.Exception.Message) + +Script: +$PSCommandPath + +Install root: +$InstallRoot +"@ + Write-Host "" + Write-Host $errorText -ForegroundColor Red + Write-InstallResult $errorText +} From df04cdcf294328262e5df3b0107f690150c739f2 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 9 Jun 2026 15:14:00 +0300 Subject: [PATCH 4/7] fix: use deployed ai workspace hub by default --- infra/.env.example | 7 +++++++ infra/docker-compose.dev.yml | 3 ++- infra/scripts/init-dev-env.sh | 10 ++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/infra/.env.example b/infra/.env.example index 1f799b8..30da4fc 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -59,3 +59,10 @@ AI_WORKSPACE_PG_USER=nodedc_ai_workspace AI_WORKSPACE_PG_PASS=change-me-generate-with-infra-scripts-init-dev-env AI_WORKSPACE_ASSISTANT_TOKEN=change-me-generate-with-infra-scripts-init-dev-env NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082 + +# AI Workspace Hub for downloaded Codex workers. +# Default local development uses the deployed relay. Override these only for offline Hub development. +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 +AI_WORKSPACE_HUB_FALLBACK_URLS= diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index c6c2924..1dac0f3 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -162,7 +162,8 @@ services: PORT: 18082 DATABASE_URL: postgres://${AI_WORKSPACE_PG_USER:-nodedc_ai_workspace}:${AI_WORKSPACE_PG_PASS:-nodedc_ai_workspace}@ai-workspace-postgres:5432/${AI_WORKSPACE_PG_DB:-nodedc_ai_workspace} AI_WORKSPACE_ASSISTANT_TOKEN: ${AI_WORKSPACE_ASSISTANT_TOKEN:-dev-ai-workspace-assistant-token} - AI_WORKSPACE_HUB_PUBLIC_URL: ${AI_WORKSPACE_HUB_PUBLIC_URL:-ws://host.docker.internal:18081/api/ai-workspace/hub} + AI_WORKSPACE_HUB_PUBLIC_URL: ${AI_WORKSPACE_HUB_PUBLIC_URL:-wss://ai-hub.nodedc.ru/api/ai-workspace/hub} + AI_WORKSPACE_HUB_FALLBACK_URLS: ${AI_WORKSPACE_HUB_FALLBACK_URLS:-} expose: - "18082" ports: diff --git a/infra/scripts/init-dev-env.sh b/infra/scripts/init-dev-env.sh index 97ade7c..0c3244f 100755 --- a/infra/scripts/init-dev-env.sh +++ b/infra/scripts/init-dev-env.sh @@ -59,6 +59,16 @@ SESSION_SECRET=$(rand 48) NODEDC_INTERNAL_ACCESS_TOKEN=$(openssl rand -hex 48 | tr -d '\n') COOKIE_DOMAIN=.local.nodedc COOKIE_SECURE=false + +# AI Workspace shared registry and Hub +AI_WORKSPACE_PG_DB=nodedc_ai_workspace +AI_WORKSPACE_PG_USER=nodedc_ai_workspace +AI_WORKSPACE_PG_PASS=$(rand 36) +AI_WORKSPACE_ASSISTANT_TOKEN=$(openssl rand -hex 48 | tr -d '\n') +AI_WORKSPACE_HUB_TOKEN=$(openssl rand -hex 48 | tr -d '\n') +AI_WORKSPACE_HUB_HOST_BIND=127.0.0.1:18081 +AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub +AI_WORKSPACE_HUB_FALLBACK_URLS= EOF chmod 600 "$ENV_FILE" From 5e0204b04968fcd475d8f5cb1f40689c6d448630 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 9 Jun 2026 15:48:04 +0300 Subject: [PATCH 5/7] fix: document deployed ai workspace hub contract --- infra/synology/.env.synology.example | 2 ++ infra/synology/README.md | 20 +++++++++++++++++++ infra/synology/apply-current-runtime.sh | 4 ++++ .../synology/docker-compose.platform-http.yml | 2 ++ infra/synology/verify-current-runtime.sh | 4 ++++ 5 files changed, 32 insertions(+) diff --git a/infra/synology/.env.synology.example b/infra/synology/.env.synology.example index 2a0eecc..71aba0d 100644 --- a/infra/synology/.env.synology.example +++ b/infra/synology/.env.synology.example @@ -65,3 +65,5 @@ NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082 AI_WORKSPACE_HUB_TOKEN=replace-with-random-synology-secret AI_WORKSPACE_HUB_HOST_BIND=0.0.0.0:18081 +AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub +AI_WORKSPACE_HUB_FALLBACK_URLS= diff --git a/infra/synology/README.md b/infra/synology/README.md index d6a22f0..54b13ba 100644 --- a/infra/synology/README.md +++ b/infra/synology/README.md @@ -26,6 +26,26 @@ https://ai-hub.nodedc.ru -> AI Workspace Hub / WebSocket relay В `Caddyfile.http` эти домены проксируются через локальный HTTP edge, но upstream получает `X-Forwarded-Proto: https` и `X-Forwarded-Port: 443`. +## AI Workspace Hub / Assistant contract + +`ai-workspace-hub` remains a thin public WebSocket relay. It is the only AI Workspace service exposed through DSM/Nginx as `https://ai-hub.nodedc.ru`. + +`ai-workspace-assistant` is an internal platform service for per-user executors, selected device, shared threads and installer generation. It must not be exposed as a public route. Launcher, Engine and Tasker use it through Docker networking: + +```env +NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082 +PLANE_NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082 +``` + +Installer generation must always point remote Codex workers to the deployed Hub: + +```env +AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub +AI_WORKSPACE_HUB_FALLBACK_URLS= +``` + +Server-side Hub API calls require a token accepted by Hub: `AI_WORKSPACE_HUB_TOKEN`, `NDC_AI_WORKSPACE_HUB_TOKEN`, or the shared `NODEDC_INTERNAL_ACCESS_TOKEN` where that token is intentionally common across platform services. + ## Локальные домены для первичной проверки На Mac для первичной проверки добавить в `/etc/hosts`: diff --git a/infra/synology/apply-current-runtime.sh b/infra/synology/apply-current-runtime.sh index ef025eb..677a33e 100755 --- a/infra/synology/apply-current-runtime.sh +++ b/infra/synology/apply-current-runtime.sh @@ -53,6 +53,10 @@ echo "== ai workspace assistant health check ==" "${DOCKER_BIN}" exec nodedc-platform-ai-workspace-assistant-1 sh -lc \ 'node -e '"'"'fetch("http://127.0.0.1:18082/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"'' +echo "== ai workspace assistant hub target check ==" +"${DOCKER_BIN}" exec nodedc-platform-ai-workspace-assistant-1 sh -lc \ + 'test "$AI_WORKSPACE_HUB_PUBLIC_URL" = "wss://ai-hub.nodedc.ru/api/ai-workspace/hub" && test -z "$AI_WORKSPACE_HUB_FALLBACK_URLS" && echo ai-workspace-prod-hub-target-ok' + echo "== clear authentik global brand css ==" DOCKER_BIN="${DOCKER_BIN}" bash "${PLATFORM_DIR}/clear-authentik-brand-css.sh" diff --git a/infra/synology/docker-compose.platform-http.yml b/infra/synology/docker-compose.platform-http.yml index 7d38544..2b922d6 100644 --- a/infra/synology/docker-compose.platform-http.yml +++ b/infra/synology/docker-compose.platform-http.yml @@ -138,6 +138,8 @@ services: DATABASE_URL: postgres://${AI_WORKSPACE_PG_USER:-nodedc_ai_workspace}:${AI_WORKSPACE_PG_PASS:?ai workspace database password required}@ai-workspace-postgres:5432/${AI_WORKSPACE_PG_DB:-nodedc_ai_workspace} AI_WORKSPACE_ASSISTANT_TOKEN: ${AI_WORKSPACE_ASSISTANT_TOKEN:?ai workspace assistant token required} NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-} + AI_WORKSPACE_HUB_PUBLIC_URL: ${AI_WORKSPACE_HUB_PUBLIC_URL:-wss://ai-hub.nodedc.ru/api/ai-workspace/hub} + AI_WORKSPACE_HUB_FALLBACK_URLS: ${AI_WORKSPACE_HUB_FALLBACK_URLS:-} expose: - "18082" ports: diff --git a/infra/synology/verify-current-runtime.sh b/infra/synology/verify-current-runtime.sh index 9541a67..9944d96 100755 --- a/infra/synology/verify-current-runtime.sh +++ b/infra/synology/verify-current-runtime.sh @@ -63,6 +63,10 @@ echo "== ai workspace assistant health check ==" "${DOCKER_BIN}" exec nodedc-platform-ai-workspace-assistant-1 sh -lc \ 'node -e '"'"'fetch("http://127.0.0.1:18082/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"'' +echo "== ai workspace assistant hub target check ==" +"${DOCKER_BIN}" exec nodedc-platform-ai-workspace-assistant-1 sh -lc \ + 'test "$AI_WORKSPACE_HUB_PUBLIC_URL" = "wss://ai-hub.nodedc.ru/api/ai-workspace/hub" && test -z "$AI_WORKSPACE_HUB_FALLBACK_URLS" && echo ai-workspace-prod-hub-target-ok' + echo "== auth flow check ==" auth_flow="$(fetch_with_retry https://id.nodedc.ru/if/flow/default-authentication-flow/)" printf '%s' "$auth_flow" \ From 385e2732e8ed158e08ba53ce2f247bbe512d8f10 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 13 Jun 2026 11:34:40 +0300 Subject: [PATCH 6/7] feat: sync platform ai workspace services --- infra/.env.example | 1 + infra/docker-compose.dev.yml | 2 + infra/synology/.env.synology.example | 1 + infra/synology/README.md | 3 +- .../synology/docker-compose.platform-http.yml | 2 + .../ai-workspace-assistant/src/server.mjs | 1670 ++++++++++++++++- .../src/templates/install-windows.ps1 | 416 +++- services/ai-workspace-hub/src/server.mjs | 3 +- 8 files changed, 2039 insertions(+), 59 deletions(-) diff --git a/infra/.env.example b/infra/.env.example index 30da4fc..af88641 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -65,4 +65,5 @@ NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082 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 +AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru AI_WORKSPACE_HUB_FALLBACK_URLS= diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index 1dac0f3..913cbd5 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -163,6 +163,8 @@ services: DATABASE_URL: postgres://${AI_WORKSPACE_PG_USER:-nodedc_ai_workspace}:${AI_WORKSPACE_PG_PASS:-nodedc_ai_workspace}@ai-workspace-postgres:5432/${AI_WORKSPACE_PG_DB:-nodedc_ai_workspace} AI_WORKSPACE_ASSISTANT_TOKEN: ${AI_WORKSPACE_ASSISTANT_TOKEN:-dev-ai-workspace-assistant-token} AI_WORKSPACE_HUB_PUBLIC_URL: ${AI_WORKSPACE_HUB_PUBLIC_URL:-wss://ai-hub.nodedc.ru/api/ai-workspace/hub} + 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:-} expose: - "18082" diff --git a/infra/synology/.env.synology.example b/infra/synology/.env.synology.example index 71aba0d..c77550e 100644 --- a/infra/synology/.env.synology.example +++ b/infra/synology/.env.synology.example @@ -66,4 +66,5 @@ NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082 AI_WORKSPACE_HUB_TOKEN=replace-with-random-synology-secret AI_WORKSPACE_HUB_HOST_BIND=0.0.0.0:18081 AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub +AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru AI_WORKSPACE_HUB_FALLBACK_URLS= diff --git a/infra/synology/README.md b/infra/synology/README.md index 54b13ba..70abe08 100644 --- a/infra/synology/README.md +++ b/infra/synology/README.md @@ -41,10 +41,11 @@ Installer generation must always point remote Codex workers to the deployed Hub: ```env AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub +AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru AI_WORKSPACE_HUB_FALLBACK_URLS= ``` -Server-side Hub API calls require a token accepted by Hub: `AI_WORKSPACE_HUB_TOKEN`, `NDC_AI_WORKSPACE_HUB_TOKEN`, or the shared `NODEDC_INTERNAL_ACCESS_TOKEN` where that token is intentionally common across platform services. +Server-side Hub API calls, including executor status checks, use `AI_WORKSPACE_HUB_INTERNAL_URL` and require a token accepted by Hub: `AI_WORKSPACE_HUB_TOKEN`, `NDC_AI_WORKSPACE_HUB_TOKEN`, or the shared `NODEDC_INTERNAL_ACCESS_TOKEN` where that token is intentionally common across platform services. ## Локальные домены для первичной проверки diff --git a/infra/synology/docker-compose.platform-http.yml b/infra/synology/docker-compose.platform-http.yml index 2b922d6..f07b1a0 100644 --- a/infra/synology/docker-compose.platform-http.yml +++ b/infra/synology/docker-compose.platform-http.yml @@ -139,6 +139,8 @@ services: AI_WORKSPACE_ASSISTANT_TOKEN: ${AI_WORKSPACE_ASSISTANT_TOKEN:?ai workspace assistant token required} NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-} AI_WORKSPACE_HUB_PUBLIC_URL: ${AI_WORKSPACE_HUB_PUBLIC_URL:-wss://ai-hub.nodedc.ru/api/ai-workspace/hub} + 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:-} expose: - "18082" diff --git a/services/ai-workspace-assistant/src/server.mjs b/services/ai-workspace-assistant/src/server.mjs index ee23d62..65c13ff 100644 --- a/services/ai-workspace-assistant/src/server.mjs +++ b/services/ai-workspace-assistant/src/server.mjs @@ -11,11 +11,15 @@ const SUPPORTED_EXECUTOR_STATUSES = new Set(["unknown", "online", "offline", "ch 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 BRIDGE_RUN_MIRROR_POLL_MS = 1200; +const BRIDGE_RUN_MIRROR_MAX_MS = 12 * 60 * 60 * 1000; const config = readConfig(); const pool = new Pool({ connectionString: config.databaseUrl, max: config.databasePoolSize }); const app = express(); const httpServer = createServer(app); +const activeBridgeRunMirrors = new Map(); app.disable("x-powered-by"); app.use(express.json({ limit: "2mb" })); @@ -26,17 +30,30 @@ app.get("/healthz", asyncRoute(async (_req, res) => { ok: true, service: "nodedc-ai-workspace-assistant", database: "ready", - internalApiConfigured: Boolean(config.internalAccessToken), + internalApiConfigured: config.internalAccessTokens.length > 0, }); })); +app.get("/api/ai-workspace/assistant/v1/settings", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const settings = await getOwnerSettings(owner); + res.json({ ok: true, owner: publicOwner(owner), settings: publicOwnerSettings(settings) }); +})); + +app.patch("/api/ai-workspace/assistant/v1/settings", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const command = sanitizeOwnerSettingsCommand(req.body); + const settings = await updateOwnerSettings(owner, command); + res.json({ ok: true, owner: publicOwner(owner), settings: publicOwnerSettings(settings) }); +})); + app.get("/api/ai-workspace/assistant/v1/executors", requireInternalApi, asyncRoute(async (req, res) => { const owner = getRequestOwner(req); const [executors, settings] = await Promise.all([ listExecutors(owner), getOwnerSettings(owner), ]); - res.json({ ok: true, owner: publicOwner(owner), selectedExecutorId: settings.selectedExecutorId, executors }); + res.json({ ok: true, owner: publicOwner(owner), selectedExecutorId: settings.selectedExecutorId, settings: publicOwnerSettings(settings), executors }); })); app.post("/api/ai-workspace/assistant/v1/executors", requireInternalApi, asyncRoute(async (req, res) => { @@ -84,6 +101,51 @@ app.post("/api/ai-workspace/assistant/v1/executors/:executorId/select", requireI res.json({ ok: true, owner: publicOwner(owner), selectedExecutorId: executor.id, executor }); })); +app.post("/api/ai-workspace/assistant/v1/executors/:executorId/check", 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 check = await checkExecutor(executor); + const updated = await updateExecutor(owner, executor.id, { + status: check.status, + statusDetail: check.statusDetail, + lastSeenAt: check.lastSeenAt, + metadata: { + ...(executor.metadata || {}), + lastCheck: check.details, + }, + }); + res.json({ ok: true, owner: publicOwner(owner), executor: updated || executor, check: check.details }); +})); + +app.get("/api/ai-workspace/assistant/v1/executors/:executorId/events", 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 latestOnly = String(req.query.since || "") === "latest"; + const payload = await readExecutorEvents(executor, { + since: latestOnly ? 0 : Number(req.query.since || 0), + limit: sanitizeLimit(req.query.limit, 100, 500), + }); + res.json({ + ok: true, + owner: publicOwner(owner), + executorId: executor.id, + online: payload.online, + latestEventId: payload.latestEventId, + events: latestOnly ? [] : payload.events, + }); +})); + app.get("/api/ai-workspace/assistant/v1/executors/:executorId/agent/windows.ps1", requireInternalApi, asyncRoute(async (req, res) => { const owner = getRequestOwner(req); const executorId = sanitizeUuid(req.params.executorId, "executorId"); @@ -97,6 +159,13 @@ app.get("/api/ai-workspace/assistant/v1/executors/:executorId/agent/windows.ps1" res.status(400).send("installer_port_invalid"); return; } + const ownerSettings = await getOwnerSettings(owner); + const appMcpServers = installerMcpServersFromSettings(ownerSettings, req.query); + const opsMcpServer = appMcpServers.find((server) => server.appId === "ops") || null; + const opsMcpUrl = optionalString(opsMcpServer?.url) || ""; + const opsMcpToken = bearerTokenFromHeaders(opsMcpServer?.httpHeaders) || ""; + const opsMcpServerName = optionalString(opsMcpServer?.serverName) || "nodedc_tasker"; + const appMcpServersJson = appMcpServers.length ? JSON.stringify(appMcpServers) : ""; const templateUrl = new URL("./templates/install-windows.ps1", import.meta.url); let source = await readFile(templateUrl, "utf8"); @@ -105,6 +174,10 @@ app.get("/api/ai-workspace/assistant/v1/executors/:executorId/agent/windows.ps1" .replace('[string]$HubFallbackUrls = "",', `[string]$HubFallbackUrls = ${psSingle(config.hubFallbackWebSocketUrls.join(","))},`) .replace('[string]$PairingCode = "",', `[string]$PairingCode = ${psSingle(executor.pairingCode || "")},`) .replace('[string]$MachineName = "",', `[string]$MachineName = ${psSingle(executor.name)},`) + .replace('[string]$OpsMcpUrl = "",', `[string]$OpsMcpUrl = ${psSingle(opsMcpUrl)},`) + .replace('[string]$OpsMcpToken = "",', `[string]$OpsMcpToken = ${psSingle(opsMcpToken)},`) + .replace('[string]$OpsMcpServerName = "nodedc_tasker",', `[string]$OpsMcpServerName = ${psSingle(opsMcpServerName)},`) + .replace('[string]$AppMcpServersJson = "",', `[string]$AppMcpServersJson = ${psSingle(appMcpServersJson)},`) .replace('[int]$Port = 8787,', `[int]$Port = ${installerPort},`) .replace('[string]$Workspace = "",', `[string]$Workspace = ${psSingle(executor.workspacePath || "")},`); @@ -123,8 +196,10 @@ app.get("/api/ai-workspace/assistant/v1/executors/:executorId/agent/windows.ps1" app.get("/api/ai-workspace/assistant/v1/threads", requireInternalApi, asyncRoute(async (req, res) => { const owner = getRequestOwner(req); const surface = req.query.surface ? sanitizeSurface(req.query.surface) : null; + const kind = sanitizeThreadKind(req.query.kind || req.query.threadKind || req.query.thread_kind || "shared"); + const includeArchived = isTruthy(req.query.includeArchived || req.query.include_archived); const limit = sanitizeLimit(req.query.limit, 100, 200); - const threads = await listThreads(owner, { surface, limit }); + const threads = await listThreads(owner, { surface, kind, limit, includeArchived }); res.json({ ok: true, owner: publicOwner(owner), threads }); })); @@ -184,6 +259,130 @@ app.post("/api/ai-workspace/assistant/v1/threads/:threadId/messages", requireInt res.status(201).json({ ok: true, owner: publicOwner(owner), message }); })); +app.post("/api/ai-workspace/assistant/v1/threads/:threadId/dispatch", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const threadId = sanitizeUuid(req.params.threadId, "threadId"); + const command = sanitizeDispatchCommand(req.body); + const thread = await getThread(owner, threadId); + if (!thread) { + res.status(404).json({ ok: false, error: "ai_workspace_thread_not_found" }); + return; + } + const message = await getThreadMessage(owner, threadId, command.messageId); + if (!message) { + res.status(404).json({ ok: false, error: "ai_workspace_thread_message_not_found" }); + return; + } + if (message.role !== "user") { + res.status(400).json({ ok: false, error: "ai_workspace_dispatch_requires_user_message" }); + return; + } + + const ownerSettings = await getOwnerSettings(owner); + const executorId = command.selectedExecutorId || thread.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 history = await listThreadMessages(owner, threadId, { limit: 40, before: null }); + const bridgePayload = buildBridgeMessagePayload({ thread, message, history, executor, command, ownerSettings }); + const missingContext = missingNdcAgentCoreContext(bridgePayload.context); + const missingOpsContext = missingOpsContextFields(bridgePayload.context); + if (missingContext.length > 0) { + const guardInstruction = ndcMissingContextInstruction(bridgePayload.context, missingContext); + bridgePayload.context = { + ...bridgePayload.context, + contextReady: false, + missingContext, + accessMode: "chat-readonly", + guardInstruction, + }; + } else if (normalizeKey(bridgePayload.context?.modeId) === "ndc-agent-core") { + bridgePayload.context = { + ...bridgePayload.context, + contextReady: true, + missingContext: [], + accessMode: "agent-write", + }; + } else if (missingOpsContext.length > 0) { + bridgePayload.context = { + ...bridgePayload.context, + contextReady: false, + missingContext: missingOpsContext, + accessMode: "chat-readonly", + guardInstruction: opsMissingContextInstruction(bridgePayload.context, missingOpsContext), + }; + } else if (normalizeKey(bridgePayload.context?.modeId) === "ops") { + bridgePayload.context = { + ...bridgePayload.context, + contextReady: true, + missingContext: [], + accessMode: "ops-write", + }; + } + let bridge = null; + try { + bridge = await dispatchExecutorMessage(executor, bridgePayload); + } catch (error) { + const failureText = bridgeFailureText({ message: errorMessage(error) }); + const assistantMessage = await createThreadMessage(owner, threadId, { + role: "assistant", + content: failureText, + payload: { + surface: optionalString(bridgePayload.context?.surface) || thread.originSurface || "global", + kind: "bridge_failure", + executorId: executor.id, + modeId: bridgePayload.context?.modeId, + }, + }); + await updateExecutor(owner, executor.id, { + status: "offline", + statusDetail: sanitizeBridgeErrorText(errorMessage(error)), + lastSeenAt: executor.lastSeenAt, + metadata: { + ...(executor.metadata || {}), + lastDispatchError: { + at: new Date().toISOString(), + message: sanitizeBridgeErrorText(errorMessage(error)), + }, + }, + }).catch(() => null); + res.json({ + ok: true, + owner: publicOwner(owner), + threadId, + executor, + message, + assistantMessage, + bridge: { + ok: false, + accepted: false, + mode: executor.connectionMode === "direct" ? "direct" : "hub", + error: sanitizeBridgeErrorText(errorMessage(error)), + }, + }); + return; + } + const run = await createBridgeRun({ owner, thread, executor, bridge, payload: bridgePayload }); + startBridgeRunMirror({ owner, thread, executor, bridge, run }); + + res.json({ + ok: true, + owner: publicOwner(owner), + threadId, + executor, + message, + assistantMessage: null, + bridge, + }); +})); + app.use((error, _req, res, _next) => { const status = Number(error?.status || 500); const message = error instanceof Error ? error.message : "internal_error"; @@ -191,6 +390,7 @@ app.use((error, _req, res, _next) => { }); await migrate(); +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}`); @@ -207,7 +407,47 @@ async function listExecutors(owner) { order by updated_at desc, created_at desc`, [owner.key] ); - return result.rows.map(toExecutor); + return Promise.all(result.rows.map((row) => decorateExecutorLiveStatus(toExecutor(row)))); +} + +async function decorateExecutorLiveStatus(executor) { + if (!(executor?.connectionMode === "hub" || executor?.pairingCode)) return executor; + const pairingCode = cleanPairingCode(executor.pairingCode); + if (!pairingCode) { + return { ...executor, status: "offline", statusDetail: "pairing_code_required" }; + } + if (!config.hubInternalHttpUrl || !config.hubInternalAccessToken) return executor; + + try { + const payload = await hubRequestJson( + `/api/ai-workspace/hub/v1/agents/${encodeURIComponent(pairingCode)}`, + { method: "GET" }, + 1800 + ); + const agent = isPlainObject(payload.agent) ? payload.agent : null; + if (agent?.online && agent.runtimeStatus !== "error") { + return { + ...executor, + status: "online", + statusDetail: "", + lastSeenAt: optionalString(agent.lastSeenAt) || executor.lastSeenAt, + }; + } + return { + ...executor, + status: "offline", + statusDetail: agent?.runtimeStatus === "error" + ? optionalString(agent.runtimeError) || "bridge_runtime_error" + : "bridge_agent_offline", + lastSeenAt: optionalString(agent?.lastSeenAt) || executor.lastSeenAt, + }; + } catch (error) { + return { + ...executor, + status: "offline", + statusDetail: errorMessage(error) || "bridge_agent_offline", + }; + } } async function createExecutor(owner, command) { @@ -357,23 +597,1135 @@ async function selectExecutor(owner, executorId) { return executor; } +async function updateOwnerSettings(owner, command) { + const current = await getOwnerSettings(owner); + const selectedExecutorId = Object.hasOwn(command, "selectedExecutorId") + ? command.selectedExecutorId + : current.selectedExecutorId; + if (selectedExecutorId && !(await getExecutor(owner, selectedExecutorId))) { + const error = new Error("ai_workspace_executor_not_found"); + error.status = 404; + throw error; + } + const activeContext = Object.hasOwn(command, "activeContext") + ? mergeOwnerActiveContext(current.activeContext || {}, command.activeContext) + : current.activeContext || {}; + const enabledToolPacks = Object.hasOwn(command, "enabledToolPacks") + ? mergeToolPacks(current.enabledToolPacks, command.enabledToolPacks) + : current.enabledToolPacks || []; + const metadata = Object.hasOwn(command, "metadata") + ? mergeOwnerMetadata(current.metadata || {}, command.metadata) + : current.metadata || {}; + const result = await pool.query( + `insert into ai_workspace_owner_settings ( + owner_key, + owner_user_id, + owner_email, + selected_executor_id, + active_context, + enabled_tool_packs, + metadata + ) values ($1, $2, $3, $4, $5::jsonb, $6::text[], $7::jsonb) + on conflict (owner_key) do update set + owner_user_id = excluded.owner_user_id, + owner_email = excluded.owner_email, + selected_executor_id = excluded.selected_executor_id, + active_context = excluded.active_context, + enabled_tool_packs = excluded.enabled_tool_packs, + metadata = excluded.metadata, + updated_at = now() + returning *`, + [ + owner.key, + owner.userId, + owner.email, + selectedExecutorId, + JSON.stringify(activeContext), + enabledToolPacks, + JSON.stringify(metadata), + ] + ); + return toOwnerSettings(result.rows[0]); +} + +function mergeOwnerActiveContext(current, incoming) { + const currentContext = isPlainObject(current) ? current : {}; + const incomingContext = isPlainObject(incoming) ? incoming : {}; + const currentContexts = isPlainObject(currentContext.contexts) ? currentContext.contexts : {}; + const incomingContexts = isPlainObject(incomingContext.contexts) ? incomingContext.contexts : {}; + const surface = normalizeKey(incomingContext.surface); + const nextContexts = { ...currentContexts }; + + for (const [key, value] of Object.entries(incomingContexts)) { + const normalizedKey = normalizeKey(key); + if (!normalizedKey || !isPlainObject(value)) continue; + const { contexts: _nestedContexts, ...surfaceContext } = value; + nextContexts[normalizedKey] = { + ...(isPlainObject(nextContexts[normalizedKey]) ? nextContexts[normalizedKey] : {}), + ...surfaceContext, + }; + } + + if ((surface === "engine" || surface === "ops") && !isPlainObject(incomingContexts[surface])) { + const { contexts: _contexts, ...surfaceContext } = incomingContext; + nextContexts[surface] = { + ...(isPlainObject(nextContexts[surface]) ? nextContexts[surface] : {}), + ...surfaceContext, + }; + } + + return { + ...currentContext, + ...incomingContext, + contexts: nextContexts, + }; +} + +function mergeOwnerMetadata(current, incoming) { + const currentMetadata = isPlainObject(current) ? current : {}; + const incomingMetadata = isPlainObject(incoming) ? incoming : {}; + const currentAppGrants = isPlainObject(currentMetadata.appGrants) ? currentMetadata.appGrants : {}; + const incomingAppGrants = isPlainObject(incomingMetadata.appGrants) ? incomingMetadata.appGrants : {}; + const nextAppGrants = { ...currentAppGrants }; + + for (const [key, value] of Object.entries(incomingAppGrants)) { + const appId = normalizeKey(key); + if (!appId) continue; + if (value === null || value === false) { + delete nextAppGrants[appId]; + continue; + } + if (!isPlainObject(value)) continue; + nextAppGrants[appId] = { + ...(isPlainObject(nextAppGrants[appId]) ? nextAppGrants[appId] : {}), + ...value, + appId: optionalString(value.appId) || appId, + }; + } + + return { + ...currentMetadata, + ...incomingMetadata, + appGrants: nextAppGrants, + }; +} + +function installerMcpServersFromSettings(settings, query = {}) { + const servers = []; + const metadata = isPlainObject(settings?.metadata) ? settings.metadata : {}; + collectInstallerMcpServers(servers, metadata.mcpServers, {}); + const appGrants = isPlainObject(metadata.appGrants) ? metadata.appGrants : {}; + for (const [appId, grant] of Object.entries(appGrants)) { + if (!isPlainObject(grant)) continue; + collectInstallerMcpServers(servers, grant.mcpServers, { + appId: optionalString(grant.appId) || normalizeKey(appId), + appTitle: optionalString(grant.appTitle || grant.title), + }); + } + + const legacyOpsUrl = optionalString(query.opsMcpUrl || query.ops_mcp_url); + const legacyOpsToken = optionalString(query.opsMcpToken || query.ops_mcp_token); + if (legacyOpsUrl && legacyOpsToken) { + servers.push({ + appId: "ops", + appTitle: "NODE.DC Ops", + serverName: optionalString(query.opsMcpServerName || query.ops_mcp_server_name) || "nodedc_tasker", + url: legacyOpsUrl, + enabled: true, + required: false, + startupTimeoutSec: 20, + toolTimeoutSec: 60, + httpHeaders: { + Authorization: `Bearer ${legacyOpsToken}`, + Accept: "application/json", + "MCP-Protocol-Version": "2025-06-18", + }, + }); + } + + const byServerName = new Map(); + for (const server of servers.map(sanitizeInstallerMcpServer).filter(Boolean)) { + byServerName.set(server.serverName, server); + } + return Array.from(byServerName.values()); +} + +function collectInstallerMcpServers(target, value, defaults = {}) { + const items = Array.isArray(value) + ? value + : isPlainObject(value) + ? Object.values(value) + : []; + for (const item of items) { + if (!isPlainObject(item)) continue; + target.push({ ...defaults, ...item }); + } +} + +function sanitizeInstallerMcpServer(raw) { + if (!isPlainObject(raw)) return null; + const serverName = safeMcpServerName(raw.serverName || raw.server_name || raw.name); + const url = optionalString(raw.url); + if (!serverName || !url) return null; + const httpHeaders = sanitizeInstallerMcpHeaders(raw.httpHeaders || raw.http_headers || raw.headers); + return { + appId: normalizeKey(raw.appId || raw.app_id) || "global", + appTitle: optionalString(raw.appTitle || raw.app_title || raw.title), + serverName, + url, + enabled: raw.enabled !== false, + required: raw.required === true, + startupTimeoutSec: sanitizeInteger(raw.startupTimeoutSec || raw.startup_timeout_sec, 20, 1, 600), + toolTimeoutSec: sanitizeInteger(raw.toolTimeoutSec || raw.tool_timeout_sec, 60, 1, 3600), + httpHeaders, + }; +} + +function sanitizeInstallerMcpHeaders(value) { + if (!isPlainObject(value)) return {}; + const headers = {}; + for (const [key, rawValue] of Object.entries(value)) { + const headerName = optionalString(key); + const headerValue = optionalString(rawValue); + if (!headerName || !headerValue || headerName.length > 120 || headerValue.length > 4000) continue; + headers[headerName] = headerValue; + } + return headers; +} + +function bearerTokenFromHeaders(headers) { + if (!isPlainObject(headers)) return ""; + const authorization = optionalString(headers.Authorization || headers.authorization); + const match = authorization?.match(/^Bearer\s+(.+)$/i); + return match ? match[1].trim() : ""; +} + +function safeMcpServerName(value) { + const text = optionalString(value); + if (!text) return ""; + return text.replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 80); +} + +function sanitizeInteger(value, fallback, min, max) { + const number = Number(value || fallback); + if (!Number.isFinite(number)) return fallback; + return Math.min(Math.max(Math.trunc(number), min), max); +} + +async function checkExecutor(executor) { + const checkedAt = new Date().toISOString(); + if (executor.connectionMode === "direct") { + return checkDirectExecutor(executor, checkedAt); + } + return checkHubExecutor(executor, checkedAt); +} + +async function checkHubExecutor(executor, checkedAt) { + const pairingCode = cleanPairingCode(executor.pairingCode); + if (!pairingCode) { + return executorCheckResult("error", "pairing_code_required", checkedAt, { checkedAt, mode: "hub" }); + } + if (!config.hubInternalHttpUrl) { + return executorCheckResult("unknown", "ai_workspace_hub_url_not_configured", checkedAt, { checkedAt, mode: "hub", pairingCode }); + } + if (!config.hubInternalAccessToken) { + return executorCheckResult("unknown", "ai_workspace_hub_token_not_configured", checkedAt, { checkedAt, mode: "hub", pairingCode }); + } + + try { + const agentPayload = await hubRequestJson( + `/api/ai-workspace/hub/v1/agents/${encodeURIComponent(pairingCode)}`, + { method: "GET" }, + 5000 + ); + const agent = isPlainObject(agentPayload.agent) ? agentPayload.agent : null; + if (!agent?.online) { + return executorCheckResult("offline", "bridge_agent_offline", optionalString(agent?.lastSeenAt) || optionalString(executor.lastSeenAt) || checkedAt, { + checkedAt, + mode: "hub", + pairingCode, + agent, + }); + } + + try { + const healthPayload = await hubRequestJson( + `/api/ai-workspace/hub/v1/agents/${encodeURIComponent(pairingCode)}/request`, + { + method: "POST", + body: { + command: "health", + payload: {}, + timeoutMs: 7000, + quiet: true, + }, + }, + 9000 + ); + const health = isPlainObject(healthPayload.payload) ? healthPayload.payload : {}; + const runtimeOk = health.ok !== false && health.runtimeStatus !== "error"; + return executorCheckResult( + runtimeOk ? "online" : "error", + runtimeOk ? "bridge_health_ok" : optionalString(health.runtimeError || health.error) || "bridge_health_failed", + optionalString(health.runtimeCheckedAt) || agent.lastSeenAt || checkedAt, + { + checkedAt, + mode: "hub", + pairingCode, + agent, + health: summarizeBridgeHealth(health), + } + ); + } catch (error) { + const status = Number(error?.status || 0); + const statusDetail = errorMessage(error); + return executorCheckResult( + status === 503 ? "offline" : executorStatusForHubError(statusDetail), + statusDetail, + agent.lastSeenAt || checkedAt, + { + checkedAt, + mode: "hub", + pairingCode, + agent, + error: statusDetail, + status, + } + ); + } + } catch (error) { + const statusDetail = errorMessage(error); + return executorCheckResult(executorStatusForHubError(statusDetail), statusDetail, checkedAt, { + checkedAt, + mode: "hub", + pairingCode, + error: statusDetail, + status: Number(error?.status || 0) || null, + }); + } +} + +async function checkDirectExecutor(executor, checkedAt) { + const url = bridgeHealthUrl(executor.endpoint); + if (!url) { + return executorCheckResult("error", "bridge_endpoint_required", checkedAt, { checkedAt, mode: "direct" }); + } + + try { + const health = await fetchJson(url, { method: "GET", headers: { Accept: "application/json" } }, 7000); + const runtimeOk = health.ok !== false && health.runtimeStatus !== "error"; + return executorCheckResult( + runtimeOk ? "online" : "error", + runtimeOk ? "bridge_health_ok" : optionalString(health.runtimeError || health.error) || "bridge_health_failed", + optionalString(health.runtimeCheckedAt) || checkedAt, + { + checkedAt, + mode: "direct", + endpoint: executor.endpoint, + health: summarizeBridgeHealth(health), + } + ); + } catch (error) { + const statusDetail = errorMessage(error); + return executorCheckResult("offline", statusDetail || "bridge_direct_unavailable", checkedAt, { + checkedAt, + mode: "direct", + endpoint: executor.endpoint, + error: statusDetail, + status: Number(error?.status || 0) || null, + }); + } +} + +async function readExecutorEvents(executor, options = {}) { + if (executor.connectionMode !== "hub" && !executor.pairingCode) { + return { events: [], latestEventId: 0, online: false }; + } + const pairingCode = cleanPairingCode(executor.pairingCode); + if (!pairingCode) { + return { events: [], latestEventId: 0, online: false }; + } + const params = new URLSearchParams(); + params.set("since", String(Number(options.since || 0))); + params.set("limit", String(sanitizeLimit(options.limit, 100, 500))); + const payload = await hubRequestJson( + `/api/ai-workspace/hub/v1/agents/${encodeURIComponent(pairingCode)}/events?${params.toString()}`, + { method: "GET" }, + 10000 + ); + return { + events: Array.isArray(payload.events) ? payload.events.map(cleanBridgeEventForUi) : [], + latestEventId: Number(payload.latestEventId || 0), + online: payload.online === true, + }; +} + +async function dispatchExecutorMessage(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: "message", + payload, + timeoutMs: 12 * 60 * 60 * 1000, + quiet: false, + }, + }, + 10000 + ); + return { + ok: true, + accepted: true, + requestId: optionalString(response.requestId), + threadId: payload.threadId, + mode: "hub", + }; + } + + const url = bridgeCommandUrl(executor.endpoint, "message"); + 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), + }, + 120000 + ); + return { + ok: response?.ok !== false, + accepted: false, + response, + 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; + const context = isPlainObject(payload?.context) ? payload.context : {}; + const metadata = { + modeId: optionalString(context.modeId), + modeTitle: optionalString(context.modeTitle), + workspacePath: optionalString(payload?.workspacePath), + threadTitle: optionalString(payload?.threadTitle) || thread.title, + executorName: executor.name, + executorType: executor.type, + }; + const result = await pool.query( + `insert into ai_workspace_runs ( + id, + owner_key, + owner_user_id, + owner_email, + thread_id, + executor_id, + request_id, + origin_surface, + status, + latest_event_id, + metadata + ) values ($1, $2, $3, $4, $5, $6, $7, $8, 'running', 0, $9::jsonb) + on conflict (owner_key, thread_id, request_id) do update set + owner_user_id = excluded.owner_user_id, + owner_email = excluded.owner_email, + executor_id = excluded.executor_id, + origin_surface = excluded.origin_surface, + status = 'running', + completed_at = null, + metadata = ai_workspace_runs.metadata || excluded.metadata, + updated_at = now() + returning *`, + [ + randomUUID(), + owner.key, + owner.userId, + owner.email, + thread.id, + executor.id, + requestId, + thread.originSurface || "global", + JSON.stringify(metadata), + ] + ); + return toBridgeRun(result.rows[0]); +} + +async function updateBridgeRunState(run, patch) { + if (!run?.id) return null; + const fields = []; + const values = [run.id]; + if (Object.hasOwn(patch, "status")) { + values.push(sanitizeEnum(patch.status, SUPPORTED_RUN_STATUSES, "unsupported_run_status")); + fields.push(`status = $${values.length}`); + } + if (Object.hasOwn(patch, "latestEventId")) { + const rawLatestEventId = Number(patch.latestEventId || 0); + const latestEventId = Number.isFinite(rawLatestEventId) + ? Math.max(0, Math.trunc(rawLatestEventId)) + : 0; + values.push(latestEventId); + fields.push(`latest_event_id = greatest(latest_event_id, $${values.length})`); + } + if (Object.hasOwn(patch, "completedAt")) { + values.push(patch.completedAt ? new Date(patch.completedAt).toISOString() : null); + fields.push(`completed_at = $${values.length}::timestamptz`); + } + if (Object.hasOwn(patch, "metadata")) { + values.push(JSON.stringify(isPlainObject(patch.metadata) ? patch.metadata : {})); + fields.push(`metadata = metadata || $${values.length}::jsonb`); + } + if (fields.length === 0) return run; + const result = await pool.query( + `update ai_workspace_runs + set ${fields.join(", ")}, updated_at = now() + where id = $1 + returning *`, + values + ); + return result.rows[0] ? toBridgeRun(result.rows[0]) : null; +} + +async function persistBridgeRunEvents(run, events) { + if (!run?.id || !Array.isArray(events) || events.length === 0) return; + for (const event of events) { + const hubEventId = bridgeEventId(event); + const occurredAt = eventOccurredAt(event) || new Date().toISOString(); + await pool.query( + `insert into ai_workspace_run_events ( + id, + run_id, + owner_key, + thread_id, + request_id, + hub_event_id, + kind, + payload, + occurred_at + ) values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::timestamptz) + on conflict do nothing`, + [ + randomUUID(), + run.id, + run.ownerKey, + run.threadId, + run.requestId, + hubEventId, + optionalString(event?.kind) || "event", + JSON.stringify(isPlainObject(event) ? event : { value: event }), + occurredAt, + ] + ); + } +} + +async function listBridgeRunEvents(run, limit = 1000) { + if (!run?.id) return []; + const result = await pool.query( + `select payload + from ( + select payload, occurred_at, created_at + from ai_workspace_run_events + where run_id = $1 + order by occurred_at desc, created_at desc + limit $2 + ) e + order by occurred_at asc, created_at asc`, + [run.id, sanitizeLimit(limit, 1000, 5000)] + ); + return result.rows.map((row) => row.payload).filter(Boolean); +} + +async function recoverBridgeRunMirrors() { + if (!config.hubInternalHttpUrl || !config.hubInternalAccessToken) return; + const result = await pool.query( + `select r.*, to_jsonb(e) as executor_row + from ai_workspace_runs r + join ai_workspace_executors e on e.id = r.executor_id and e.owner_key = r.owner_key + where r.status = 'running' + and r.started_at > now() - interval '12 hours' + order by r.started_at asc + limit 200` + ); + for (const row of result.rows) { + const run = toBridgeRun(row); + const executor = row.executor_row ? toExecutor(row.executor_row) : null; + if (!executor) continue; + const owner = { key: run.ownerKey, userId: run.ownerUserId, email: run.ownerEmail }; + const thread = { id: run.threadId, originSurface: run.originSurface }; + const bridge = { accepted: true, mode: "hub", requestId: run.requestId }; + const events = await listBridgeRunEvents(run); + startBridgeRunMirror({ owner, thread, executor, bridge, run, events }); + } +} + +function startBridgeRunMirror({ owner, thread, executor, bridge, run = null, events = [] }) { + const requestId = optionalString(bridge?.requestId || run?.requestId); + if (!requestId || bridge?.accepted !== true || bridge?.mode !== "hub") return; + const threadId = thread?.id || run?.threadId; + if (!owner?.key || !threadId || !executor) return; + const key = `${owner.key}:${threadId}:${requestId}`; + if (activeBridgeRunMirrors.has(key)) return; + const restoredStartedAt = run?.startedAt ? new Date(run.startedAt).getTime() : 0; + const state = { + key, + owner, + threadId, + originSurface: thread?.originSurface || run?.originSurface || "global", + executorId: executor.id, + executor, + requestId, + run, + latestEventId: Math.max(0, Number(run?.latestEventId || 0)), + events: Array.isArray(events) ? events.slice(-1000) : [], + startedAt: Number.isFinite(restoredStartedAt) && restoredStartedAt > 0 ? restoredStartedAt : Date.now(), + }; + activeBridgeRunMirrors.set(key, state); + void mirrorBridgeRun(state); +} + +async function mirrorBridgeRun(state) { + try { + const done = await mirrorBridgeRunOnce(state); + if (done) { + activeBridgeRunMirrors.delete(state.key); + return; + } + if (Date.now() - state.startedAt > BRIDGE_RUN_MIRROR_MAX_MS) { + await createThreadMessage(state.owner, state.threadId, { + role: "assistant", + content: "Bridge request timed out before a final assistant response was persisted.", + payload: { + surface: state.originSurface, + kind: "bridge_failure", + requestId: state.requestId, + executorId: state.executorId, + }, + }); + if (state.run) { + state.run = await updateBridgeRunState(state.run, { + status: "timeout", + latestEventId: state.latestEventId, + completedAt: new Date().toISOString(), + }); + } + activeBridgeRunMirrors.delete(state.key); + return; + } + } catch { + // Transient Hub/DB failures are retried while the request remains within the mirror window. + } + setTimeout(() => void mirrorBridgeRun(state), BRIDGE_RUN_MIRROR_POLL_MS); +} + +async function mirrorBridgeRunOnce(state) { + const previousLatestEventId = state.latestEventId; + const payload = await readExecutorEvents(state.executor, { + since: state.latestEventId, + limit: 500, + }); + state.latestEventId = Math.max(state.latestEventId, Number(payload.latestEventId || 0)); + const nextEvents = (Array.isArray(payload.events) ? payload.events : []) + .filter((event) => optionalString(event?.requestId) === state.requestId); + if (nextEvents.length) { + if (state.run) await persistBridgeRunEvents(state.run, nextEvents); + state.events = [...state.events, ...nextEvents].slice(-1000); + } + if (state.run && state.latestEventId !== previousLatestEventId) { + state.run = await updateBridgeRunState(state.run, { latestEventId: state.latestEventId }); + } + + const failure = latestBridgeFailureEvent(state.events); + if (failure) { + await createThreadMessage(state.owner, state.threadId, { + role: "assistant", + content: bridgeFailureText(failure), + payload: { + surface: state.originSurface, + kind: "bridge_failure", + requestId: state.requestId, + executorId: state.executorId, + }, + }); + if (state.run) { + state.run = await updateBridgeRunState(state.run, { + status: bridgeRunStatusFromFailure(failure), + latestEventId: state.latestEventId, + completedAt: new Date().toISOString(), + }); + } + return true; + } + + const hasDone = state.events.some((event) => optionalString(event?.kind) === "done"); + if (!hasDone) return false; + const finalText = latestBridgeAssistantText(state.events); + if (!finalText) return false; + await createThreadMessage(state.owner, state.threadId, { + role: "assistant", + content: finalText, + payload: { + surface: state.originSurface, + kind: "bridge_final", + requestId: state.requestId, + executorId: state.executorId, + }, + }); + if (state.run) { + state.run = await updateBridgeRunState(state.run, { + status: "completed", + latestEventId: state.latestEventId, + completedAt: new Date().toISOString(), + }); + } + return true; +} + +function latestBridgeFailureEvent(events) { + return [...(Array.isArray(events) ? events : [])] + .reverse() + .find((event) => { + const kind = optionalString(event?.kind); + return kind === "error" || kind === "timeout"; + }) || null; +} + +function bridgeFailureText(event) { + const text = sanitizeBridgeErrorText(event?.text || event?.message || "bridge_agent_failed"); + return text.startsWith("Bridge request failed:") ? text : `Bridge request failed: ${text}`; +} + +function latestBridgeAssistantText(events) { + const event = [...(Array.isArray(events) ? events : [])] + .reverse() + .find((item) => optionalString(item?.kind) === "codex_message" && optionalString(item?.text)); + if (!event) return ""; + const text = optionalString(event.text); + return text.replace(/^Codex emitted assistant output:\s*/i, "").trim(); +} + +function bridgeRunStatusFromFailure(event) { + return optionalString(event?.kind) === "timeout" ? "timeout" : "failed"; +} + +function bridgeEventId(event) { + const value = Number(event?.id || event?.eventId || event?.event_id || 0); + return Number.isFinite(value) && value > 0 ? Math.trunc(value) : null; +} + +function eventOccurredAt(event) { + const text = optionalString(event?.at || event?.createdAt || event?.created_at || event?.time); + if (!text) return null; + const date = new Date(text); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +function buildBridgeMessagePayload({ thread, message, history, executor, command, ownerSettings }) { + const ownerContext = isPlainObject(ownerSettings?.activeContext) ? ownerSettings.activeContext : {}; + const threadContext = isPlainObject(thread.activeContext) ? thread.activeContext : {}; + const commandContext = isPlainObject(command.context) ? command.context : {}; + const surfaceContexts = mergeSurfaceContexts(threadContext, ownerContext, commandContext); + const engineContext = surfaceContexts.engine || {}; + const opsContext = surfaceContexts.ops || {}; + const commandModeId = optionalString(command.modeId); + const commandModeKey = normalizeKey(commandModeId); + const ownerContextModeKey = normalizeKey(ownerContext.modeId); + const commandContextModeKey = normalizeKey(commandContext.modeId); + const requestFromOpsMode = commandModeKey === "ops" + || commandContextModeKey === "ops" + || normalizeKey(commandContext.surface) === "ops"; + const useUnifiedAgentCore = requestFromOpsMode && isNdcAgentCoreContextReady(engineContext); + const commandProvidesNdcContext = commandModeKey === "ndc-agent-core" || commandContextModeKey === "ndc-agent-core"; + const preferOwnerContext = ownerContextModeKey === "ndc-agent-core" + && !commandProvidesNdcContext + && !commandModeId; + const effectiveThreadContext = preferOwnerContext ? {} : threadContext; + const ownerContextReady = isNdcAgentCoreContextReady(ownerContext); + const requestContextReady = isNdcAgentCoreContextReady(effectiveThreadContext) || isNdcAgentCoreContextReady(commandContext); + const promoteOwnerContext = !preferOwnerContext && ownerContextReady && !requestContextReady && !commandModeId; + const enabledToolPacks = mergeToolPacks( + ownerSettings?.enabledToolPacks, + thread.enabledToolPacks, + (preferOwnerContext || promoteOwnerContext || useUnifiedAgentCore) ? ["ndc-agent-core", "engine", "ops"] : [] + ); + const mergedContext = { + ...(preferOwnerContext || promoteOwnerContext ? ownerContext : {}), + ...effectiveThreadContext, + ...commandContext, + ...(useUnifiedAgentCore ? engineContext : {}), + }; + const mergedContextModeId = optionalString(mergedContext.modeId); + const preferContextMode = requestContextReady && (!commandModeId || commandModeKey === "ops") && mergedContextModeId; + const modeId = preferOwnerContext + ? optionalString(ownerContext.modeId) || "ndc-agent-core" + : promoteOwnerContext + ? optionalString(ownerContext.modeId) || "ndc-agent-core" + : useUnifiedAgentCore + ? optionalString(engineContext.modeId) || "ndc-agent-core" + : preferContextMode + ? mergedContextModeId + : commandModeId || mergedContextModeId || "ops"; + const commandModeTitle = optionalString(command.modeTitle); + const mergedContextModeTitle = optionalString(mergedContext.modeTitle); + const modeTitle = preferOwnerContext + ? optionalString(ownerContext.modeTitle) || "NDC Agent Core" + : promoteOwnerContext + ? optionalString(ownerContext.modeTitle) || "NDC Agent Core" + : useUnifiedAgentCore + ? optionalString(engineContext.modeTitle) || "NDC Agent Core" + : preferContextMode + ? mergedContextModeTitle || mergedContextModeId + : commandModeTitle || mergedContextModeTitle || "Ops"; + const context = { + ...mergedContext, + surface: optionalString(commandContext.surface) || optionalString(threadContext.surface) || thread.originSurface || "ops", + contexts: surfaceContexts, + engineContext, + opsContext, + enabledToolPacks, + remoteControlMode: optionalString(command.remoteControlMode || mergedContext.remoteControlMode) || "remote", + modeId, + modeTitle, + ...(useUnifiedAgentCore ? { sourceSurface: optionalString(commandContext.surface) || optionalString(threadContext.surface) || thread.originSurface || "ops" } : {}), + }; + return { + threadId: thread.id, + threadTitle: thread.title, + workspacePath: optionalString(command.workspacePath) || optionalString(executor.workspacePath) || "", + message: message.content, + displayMessage: message.content, + publicUserMessage: message.content, + resume: command.resume === true, + enabledToolPacks, + history: Array.isArray(history) + ? history.slice(-40).map((item) => ({ + role: optionalString(item.role), + text: optionalString(item.content), + })).filter((item) => item.role && item.text) + : [], + context, + client: { + surface: optionalString(context.surface) || thread.originSurface || "ops", + protocol: "ai-workspace-bridge/v1", + sentAt: new Date().toISOString(), + }, + }; +} + +function mergeSurfaceContexts(...contexts) { + const out = {}; + for (const context of contexts) { + if (!isPlainObject(context)) continue; + const bank = isPlainObject(context.contexts) ? context.contexts : {}; + for (const [key, value] of Object.entries(bank)) { + const normalizedKey = normalizeKey(key); + if (!normalizedKey || !isPlainObject(value)) continue; + out[normalizedKey] = mergeBridgeContextObject(out[normalizedKey], value); + } + const surface = normalizeKey(context.surface); + const mode = normalizeKey(context.modeId); + const inferredSurface = surface === "engine" || surface === "ops" + ? surface + : mode === "ndc-agent-core" + ? "engine" + : mode === "ops" + ? "ops" + : ""; + if (inferredSurface) { + const { contexts: _contexts, engineContext: _engineContext, opsContext: _opsContext, ...plain } = context; + out[inferredSurface] = mergeBridgeContextObject(out[inferredSurface], plain); + } + } + return out; +} + +function mergeBridgeContextObject(current, incoming) { + const out = isPlainObject(current) ? { ...current } : {}; + if (!isPlainObject(incoming)) return out; + for (const [key, value] of Object.entries(incoming)) { + if (key === "contexts") continue; + if (isBridgeEmptyContextValue(value) && !isBridgeEmptyContextValue(out[key])) continue; + if (isPlainObject(value) && isPlainObject(out[key])) { + out[key] = mergeBridgeContextObject(out[key], value); + } else { + out[key] = value; + } + } + return out; +} + +function isBridgeEmptyContextValue(value) { + if (value === null || value === undefined) return true; + if (typeof value === "string") return value.trim() === ""; + if (Array.isArray(value)) return value.length === 0; + if (isPlainObject(value)) return Object.keys(value).length === 0; + return false; +} + +function isNdcAgentCoreContextReady(context) { + if (!isPlainObject(context)) return false; + return Boolean(optionalString(context.workflowId) && optionalString(context.agentNodeId)); +} + +function missingNdcAgentCoreContext(context) { + if (!isPlainObject(context) || normalizeKey(context.modeId) !== "ndc-agent-core") return []; + const missing = []; + if (!optionalString(context.workflowId)) missing.push("workflow"); + if (!optionalString(context.agentNodeId)) missing.push("agent node"); + if (!optionalString(context.roleId)) missing.push("роль"); + return missing; +} + +function missingOpsContextFields(context) { + if (!isPlainObject(context) || normalizeKey(context.modeId) !== "ops") return []; + const opsContext = isPlainObject(context.opsContext) ? context.opsContext : context; + const missing = []; + if (!optionalString(opsContext.opsWorkspaceSlug || opsContext.opsWorkspaceId)) missing.push("Ops workspace"); + if (!optionalString(opsContext.opsProjectId || opsContext.opsProjectIdentifier || opsContext.opsProjectSlug)) missing.push("Ops project"); + return missing; +} + +function ndcMissingContextInstruction(context, missingContext) { + const missing = Array.isArray(missingContext) ? missingContext.join(", ") : "context"; + const workflow = optionalString(context?.workflowTitle || context?.workflowId) || "не выбран"; + const role = optionalString(context?.roleTitle || context?.roleId) || "не выбрана"; + return [ + "System context guard:", + "The current NODE.DC Engine context is incomplete.", + `Missing: ${missing}.`, + `Selected workflow: ${workflow}.`, + `Selected role: ${role}.`, + "You still have full chat access: answer the user's question normally, in the user's language, and describe the current connection if asked.", + "Do not claim that an agent node is selected when it is missing.", + "Do not create, edit, patch, deploy, or write workflow graph changes until the missing agent node is selected.", + "If the user asks for development or graph edits, explain briefly that selecting an agent node is required for that specific write action.", + ].join("\n"); +} + +function opsMissingContextInstruction(context, missingContext) { + const missing = Array.isArray(missingContext) ? missingContext.join(", ") : "Ops context"; + const opsContext = isPlainObject(context?.opsContext) ? context.opsContext : {}; + const workspace = optionalString(opsContext.opsWorkspaceTitle || opsContext.opsWorkspaceSlug || opsContext.opsWorkspaceId) || "не выбран"; + const project = optionalString(opsContext.opsProjectTitle || opsContext.opsProjectIdentifier || opsContext.opsProjectId) || "не выбран"; + return [ + "Ops context guard:", + "The current NODE.DC Ops context is incomplete.", + `Missing: ${missing}.`, + `Selected workspace: ${workspace}.`, + `Selected project: ${project}.`, + "You still have full chat access: answer the user's question normally in the user's language.", + "Do not create, edit, move, or delete Ops tasks until workspace and project are selected.", + "If the user asks for Ops task changes, explain briefly that selecting Ops workspace and project is required for that write action.", + "Do not claim that an Engine agent node is required for Ops task writes.", + ].join("\n"); +} + +function mergeToolPacks(...values) { + const out = []; + for (const value of values) { + const items = Array.isArray(value) ? value : []; + for (const item of items) { + const normalized = optionalString(item); + if (!SUPPORTED_TOOL_PACKS.has(normalized) || out.includes(normalized)) continue; + out.push(normalized); + } + } + return out; +} + +function executorCheckResult(status, statusDetail, lastSeenAt, details = {}) { + return { + status: SUPPORTED_EXECUTOR_STATUSES.has(status) ? status : "error", + statusDetail: optionalString(statusDetail) || "bridge_check_failed", + lastSeenAt: lastSeenAt || new Date().toISOString(), + details, + }; +} + +async function hubRequestJson(path, options = {}, timeoutMs = 5000) { + const target = `${config.hubInternalHttpUrl.replace(/\/+$/, "")}${path}`; + const body = options.body === undefined ? undefined : JSON.stringify(options.body); + return fetchJson( + target, + { + method: options.method || "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${config.hubInternalAccessToken}`, + ...(body ? { "Content-Type": "application/json" } : {}), + }, + body, + }, + timeoutMs + ); +} + +async function fetchJson(url, init = {}, timeoutMs = 5000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { ...init, redirect: "manual", signal: controller.signal }); + const text = await response.text(); + let data = null; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = { ok: false, error: "invalid_json_response", body: text.slice(0, 500) }; + } + if (!response.ok) { + const error = new Error(optionalString(data?.error || data?.message) || `http_${response.status}`); + error.status = response.status; + error.payload = data; + throw error; + } + return data; + } catch (error) { + if (error?.name === "AbortError") { + const timeoutError = new Error("bridge_check_timeout"); + timeoutError.status = 504; + throw timeoutError; + } + throw error; + } finally { + clearTimeout(timer); + } +} + +function bridgeHealthUrl(endpoint) { + const text = optionalString(endpoint); + if (!text) return ""; + try { + const url = new URL(text); + const path = url.pathname.replace(/\/+$/, ""); + if (path.endsWith("/api/ai-workspace/bridge/v1/health")) { + return url.toString(); + } + if (path.endsWith("/api/ai-workspace/bridge/v1")) { + url.pathname = `${path}/health`; + return url.toString(); + } + url.pathname = `${path}/api/ai-workspace/bridge/v1/health`.replace(/\/+/g, "/"); + return url.toString(); + } catch { + return ""; + } +} + +function bridgeCommandUrl(endpoint, command) { + const text = optionalString(endpoint); + if (!text) return ""; + try { + const url = new URL(text); + const path = url.pathname.replace(/\/+$/, ""); + const suffix = String(command || "").replace(/^\/+/, ""); + if (path.endsWith(`/api/ai-workspace/bridge/v1/${suffix}`)) { + return url.toString(); + } + if (path.endsWith("/api/ai-workspace/bridge/v1")) { + url.pathname = `${path}/${suffix}`; + return url.toString(); + } + url.pathname = `${path}/api/ai-workspace/bridge/v1/${suffix}`.replace(/\/+/g, "/"); + return url.toString(); + } catch { + return ""; + } +} + +function summarizeBridgeHealth(health) { + if (!isPlainObject(health)) return {}; + return { + ok: health.ok !== false, + service: optionalString(health.service), + host: optionalString(health.host), + machineName: optionalString(health.machineName), + runtimeStatus: optionalString(health.runtimeStatus), + runtimeError: optionalString(health.runtimeError), + runtimeCheckedAt: optionalString(health.runtimeCheckedAt), + hubConnected: health.hubConnected === true, + hubMode: optionalString(health.hubMode), + }; +} + +function cleanBridgeEventForUi(event) { + if (!isPlainObject(event)) return event; + const kind = optionalString(event.kind) || ""; + const text = optionalString(event.text || event.message) || ""; + if (kind === "error") { + return { + ...event, + text: sanitizeBridgeErrorText(text), + message: "", + }; + } + if (kind !== "stdout" && kind !== "stderr") return event; + const cleaned = summarizeLegacyCodexEventText(text); + if (!cleaned || cleaned === text) return event; + return { + ...event, + kind: cleaned.startsWith("Codex emitted assistant output:") ? "codex_message" : "codex_event", + text: cleaned, + message: "", + }; +} + +function summarizeLegacyCodexEventText(value) { + const text = optionalString(value) || ""; + if (!text) return ""; + if (/^codex\s*\n/i.test(text)) { + const answer = text.replace(/^codex\s*\n/i, "").trim(); + return answer ? `Codex emitted assistant output: ${answer.slice(0, 1200)}` : "Codex emitted assistant output."; + } + if (/^tokens used/im.test(text)) return "Tokens reported."; + return text.length > 1200 ? `${text.slice(0, 1200).trim()}...` : text; +} + +function sanitizeBridgeErrorText(value) { + const text = optionalString(value) || "bridge_agent_failed"; + return text.length > 1200 ? `${text.slice(0, 1200).trim()}...` : text; +} + async function getOwnerSettings(owner) { const result = await pool.query( "select * from ai_workspace_owner_settings where owner_key = $1", [owner.key] ); - return result.rows[0] ? toOwnerSettings(result.rows[0]) : { ownerKey: owner.key, selectedExecutorId: null }; + return result.rows[0] + ? toOwnerSettings(result.rows[0]) + : { ownerKey: owner.key, selectedExecutorId: null, activeContext: {}, enabledToolPacks: [], metadata: {} }; } -async function listThreads(owner, { surface, limit }) { +async function listThreads(owner, { surface, kind = "all", limit, includeArchived = false }) { const values = [owner.key, limit]; - const surfaceSql = surface ? "and origin_surface = $3" : ""; - if (surface) values.push(surface); + const clauses = ["owner_key = $1"]; + if (!includeArchived) clauses.push("lifecycle_state = 'active'"); + if (surface) { + values.push(surface); + clauses.push(`origin_surface = $${values.length}`); + } + if (kind === "shared") { + clauses.push("coalesce(active_context->>'modeId', '') <> 'codex-remote'"); + } else if (kind === "remote") { + clauses.push("active_context->>'modeId' = 'codex-remote'"); + } const result = await pool.query( `select * from ai_workspace_threads - where owner_key = $1 - ${surfaceSql} + where ${clauses.join("\n and ")} order by updated_at desc, created_at desc limit $2`, values @@ -482,6 +1834,19 @@ async function listThreadMessages(owner, threadId, { limit, before }) { return result.rows.reverse().map(toThreadMessage); } +async function getThreadMessage(owner, threadId, messageId) { + const result = await pool.query( + `select m.* + from ai_workspace_thread_messages m + join ai_workspace_threads t on t.id = m.thread_id + where t.owner_key = $1 + and m.thread_id = $2 + and m.id = $3`, + [owner.key, threadId, messageId] + ); + return result.rows[0] ? toThreadMessage(result.rows[0]) : null; +} + async function createThreadMessage(owner, threadId, command) { const client = await pool.connect(); try { @@ -494,6 +1859,24 @@ async function createThreadMessage(owner, threadId, command) { await client.query("rollback"); return null; } + const bridgeRequestId = bridgeResultRequestId(command); + if (bridgeRequestId) { + const existing = await client.query( + `select * + from ai_workspace_thread_messages + where thread_id = $1 + and role = $2 + and payload->>'kind' in ('bridge_final', 'bridge_failure') + and payload->>'requestId' = $3 + order by sequence asc + limit 1`, + [threadId, command.role, bridgeRequestId] + ); + if (existing.rowCount > 0) { + await client.query("commit"); + return toThreadMessage(existing.rows[0]); + } + } const sequenceResult = await client.query( "select coalesce(max(sequence), 0) + 1 as next_sequence from ai_workspace_thread_messages where thread_id = $1", [threadId] @@ -522,6 +1905,13 @@ async function createThreadMessage(owner, threadId, command) { } } +function bridgeResultRequestId(command) { + if (!command || command.role !== "assistant" || !isPlainObject(command.payload)) return ""; + const kind = optionalString(command.payload.kind); + if (kind !== "bridge_final" && kind !== "bridge_failure") return ""; + return optionalString(command.payload.requestId || command.payload.request_id) || ""; +} + async function migrate() { await pool.query(` create table if not exists ai_workspace_executors ( @@ -562,6 +1952,8 @@ async function migrate() { owner_user_id text, owner_email text, selected_executor_id uuid references ai_workspace_executors(id) on delete set null, + active_context jsonb not null default '{}'::jsonb, + enabled_tool_packs text[] not null default '{}'::text[], metadata jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), @@ -595,6 +1987,12 @@ async function migrate() { ) `); + await pool.query(` + alter table ai_workspace_owner_settings + add column if not exists active_context jsonb not null default '{}'::jsonb, + add column if not exists enabled_tool_packs text[] not null default '{}'::text[] + `); + await pool.query(` create table if not exists ai_workspace_thread_messages ( id uuid primary key, @@ -610,10 +2008,56 @@ async function migrate() { ) `); + await pool.query(` + create table if not exists ai_workspace_runs ( + id uuid primary key, + owner_key text not null, + owner_user_id text, + owner_email text, + thread_id uuid not null references ai_workspace_threads(id) on delete cascade, + executor_id uuid references ai_workspace_executors(id) on delete set null, + request_id text not null, + origin_surface text not null default 'global', + status text not null default 'running', + latest_event_id bigint not null default 0, + metadata jsonb not null default '{}'::jsonb, + started_at timestamptz not null default now(), + completed_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint ai_workspace_runs_owner_check + check (owner_user_id is not null or owner_email is not null), + constraint ai_workspace_runs_surface_check + check (origin_surface in ('engine', 'ops', 'global')), + constraint ai_workspace_runs_status_check + check (status in ('running', 'completed', 'failed', 'timeout')), + unique (owner_key, thread_id, request_id) + ) + `); + + await pool.query(` + create table if not exists ai_workspace_run_events ( + id uuid primary key, + run_id uuid not null references ai_workspace_runs(id) on delete cascade, + owner_key text not null, + thread_id uuid not null references ai_workspace_threads(id) on delete cascade, + request_id text not null, + hub_event_id bigint, + kind text not null default 'event', + payload jsonb not null default '{}'::jsonb, + occurred_at timestamptz not null default now(), + created_at timestamptz not null default now() + ) + `); + await pool.query("create index if not exists ai_workspace_executors_owner_idx on ai_workspace_executors(owner_key, updated_at desc)"); await pool.query("create index if not exists ai_workspace_executors_pairing_code_idx on ai_workspace_executors(pairing_code) where pairing_code is not null"); await pool.query("create index if not exists ai_workspace_threads_owner_surface_idx on ai_workspace_threads(owner_key, origin_surface, updated_at desc)"); await pool.query("create index if not exists ai_workspace_thread_messages_thread_idx on ai_workspace_thread_messages(thread_id, sequence)"); + await pool.query("create index if not exists ai_workspace_runs_owner_thread_idx on ai_workspace_runs(owner_key, thread_id, updated_at desc)"); + await pool.query("create index if not exists ai_workspace_runs_request_idx on ai_workspace_runs(owner_key, request_id)"); + await pool.query("create index if not exists ai_workspace_run_events_run_idx on ai_workspace_run_events(run_id, occurred_at asc, created_at asc)"); + await pool.query("create unique index if not exists ai_workspace_run_events_hub_event_idx on ai_workspace_run_events(run_id, hub_event_id) where hub_event_id is not null"); } function sanitizeExecutorCommand(payload, { partial }) { @@ -690,6 +2134,32 @@ function sanitizeThreadCommand(payload, { partial }) { return command; } +function sanitizeOwnerSettingsCommand(payload) { + const source = isPlainObject(payload) ? payload : {}; + const command = {}; + if (Object.hasOwn(source, "selectedExecutorId") || Object.hasOwn(source, "selected_executor_id")) { + const selectedExecutorId = source.selectedExecutorId || source.selected_executor_id; + command.selectedExecutorId = selectedExecutorId ? sanitizeUuid(selectedExecutorId, "selectedExecutorId") : null; + } + if (Object.hasOwn(source, "activeContext") || Object.hasOwn(source, "active_context")) { + const activeContext = source.activeContext || source.active_context; + command.activeContext = isPlainObject(activeContext) ? activeContext : {}; + } + if (Object.hasOwn(source, "enabledToolPacks") || Object.hasOwn(source, "enabled_tool_packs")) { + command.enabledToolPacks = sanitizeToolPacks(source.enabledToolPacks || source.enabled_tool_packs || []); + } + if (Object.hasOwn(source, "metadata")) { + command.metadata = isPlainObject(source.metadata) ? source.metadata : {}; + } + return command; +} + +function sanitizeThreadKind(value) { + const text = normalizeKey(value || "all"); + if (text === "shared" || text === "remote" || text === "all") return text; + return "all"; +} + function sanitizeMessageCommand(payload) { const source = isPlainObject(payload) ? payload : {}; return { @@ -699,6 +2169,22 @@ function sanitizeMessageCommand(payload) { }; } +function sanitizeDispatchCommand(payload) { + const source = isPlainObject(payload) ? payload : {}; + return { + messageId: sanitizeUuid(source.messageId || source.message_id, "messageId"), + selectedExecutorId: source.selectedExecutorId || source.selected_executor_id + ? sanitizeUuid(source.selectedExecutorId || source.selected_executor_id, "selectedExecutorId") + : null, + context: isPlainObject(source.context) ? source.context : {}, + workspacePath: optionalString(source.workspacePath || source.workspace_path), + remoteControlMode: optionalString(source.remoteControlMode || source.remote_control_mode), + modeId: optionalString(source.modeId || source.mode_id), + modeTitle: optionalString(source.modeTitle || source.mode_title), + resume: source.resume === true, + }; +} + function getRequestOwner(req) { const userId = optionalString(req.headers["x-nodedc-user-id"] || req.query.userId); const email = normalizeEmail(req.headers["x-nodedc-user-email"] || req.query.email); @@ -720,6 +2206,52 @@ function publicOwner(owner) { }; } +function publicOwnerSettings(settings) { + if (!settings || !isPlainObject(settings)) return settings; + return { + ...settings, + metadata: redactOwnerSettingsMetadata(settings.metadata), + }; +} + +function redactOwnerSettingsMetadata(metadata) { + if (!isPlainObject(metadata)) return {}; + const next = { ...metadata }; + if (isPlainObject(next.appGrants)) { + const appGrants = {}; + for (const [appId, grant] of Object.entries(next.appGrants)) { + if (!isPlainObject(grant)) continue; + appGrants[appId] = { + ...grant, + mcpServers: redactMcpServers(grant.mcpServers), + }; + } + next.appGrants = appGrants; + } + if (Object.hasOwn(next, "mcpServers")) { + next.mcpServers = redactMcpServers(next.mcpServers); + } + return next; +} + +function redactMcpServers(value) { + const items = Array.isArray(value) + ? value + : isPlainObject(value) + ? Object.values(value) + : []; + return items.map((item) => { + if (!isPlainObject(item)) return null; + const redacted = { ...item }; + if (isPlainObject(redacted.httpHeaders) || isPlainObject(redacted.http_headers) || isPlainObject(redacted.headers)) { + redacted.httpHeaders = { configured: true }; + delete redacted.http_headers; + delete redacted.headers; + } + return redacted; + }).filter(Boolean); +} + function toExecutor(row) { return { id: row.id, @@ -751,6 +2283,8 @@ function toOwnerSettings(row) { ownerUserId: row.owner_user_id, ownerEmail: row.owner_email, selectedExecutorId: row.selected_executor_id, + activeContext: row.active_context || {}, + enabledToolPacks: row.enabled_tool_packs || [], metadata: row.metadata || {}, createdAt: toIso(row.created_at), updatedAt: toIso(row.updated_at), @@ -788,8 +2322,28 @@ function toThreadMessage(row) { }; } +function toBridgeRun(row) { + return { + id: row.id, + ownerKey: row.owner_key, + ownerUserId: row.owner_user_id, + ownerEmail: row.owner_email, + threadId: row.thread_id, + executorId: row.executor_id, + requestId: row.request_id, + originSurface: row.origin_surface, + status: row.status, + latestEventId: Number(row.latest_event_id || 0), + metadata: row.metadata || {}, + startedAt: toIso(row.started_at), + completedAt: toIso(row.completed_at), + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + function requireInternalApi(req, res, next) { - if (!config.internalAccessToken) { + if (!config.internalAccessTokens.length) { res.status(503).json({ ok: false, error: "ai_workspace_assistant_token_not_configured" }); return; } @@ -799,7 +2353,7 @@ function requireInternalApi(req, res, next) { const headerToken = typeof req.headers["x-nodedc-internal-token"] === "string" ? req.headers["x-nodedc-internal-token"] : ""; const requestToken = bearerToken || headerToken; - if (!safeTokenEquals(requestToken, config.internalAccessToken)) { + if (!config.internalAccessTokens.some((token) => safeTokenEquals(requestToken, token))) { res.status(401).json({ ok: false, error: "ai_workspace_assistant_unauthorized" }); return; } @@ -926,6 +2480,23 @@ function optionalString(value) { return text ? text : null; } +function uniqueStrings(values) { + const result = []; + const seen = new Set(); + for (const value of values || []) { + const text = optionalString(value); + if (!text || seen.has(text)) continue; + seen.add(text); + result.push(text); + } + return result; +} + +function isTruthy(value) { + const text = optionalString(value)?.toLowerCase() || ""; + return text === "1" || text === "true" || text === "yes" || text === "on"; +} + function normalizeEmail(value) { const text = optionalString(value); return text ? text.toLowerCase() : null; @@ -950,27 +2521,82 @@ function badRequest(message) { return error; } +function errorMessage(error) { + return String(error?.message || error?.error || error || "bridge_check_failed"); +} + +function executorStatusForHubError(message) { + const text = String(message || ""); + if ( + text === "ai_workspace_hub_token_not_configured" || + text === "ai_workspace_hub_unauthorized" || + text === "ai_workspace_hub_url_not_configured" + ) { + return "unknown"; + } + return "error"; +} + function asyncRoute(handler) { return (req, res, next) => { Promise.resolve(handler(req, res, next)).catch(next); }; } +function httpUrlFromWebSocketUrl(value) { + const text = optionalString(value); + if (!text) return ""; + try { + const url = new URL(text); + 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.search = ""; + url.hash = ""; + url.pathname = url.pathname.replace(/\/api\/ai-workspace\/hub\/?$/i, "") || "/"; + return url.toString().replace(/\/+$/, ""); + } catch { + return ""; + } +} + +function isDeployedPublicHubUrl(value) { + try { + return new URL(value).hostname === "ai-hub.nodedc.ru"; + } catch { + return false; + } +} + function readConfig() { const databaseUrl = process.env.DATABASE_URL || process.env.AI_WORKSPACE_ASSISTANT_DATABASE_URL || "postgres://nodedc_ai_workspace:nodedc_ai_workspace@localhost:5432/nodedc_ai_workspace"; + const hubWebSocketUrl = + optionalString(process.env.AI_WORKSPACE_HUB_PUBLIC_URL) || + optionalString(process.env.NDC_AI_WORKSPACE_HUB_URL) || + optionalString(process.env.AI_WORKSPACE_HUB_URL) || + "wss://ai-hub.nodedc.ru/api/ai-workspace/hub"; + const hubInternalHttpUrl = + optionalString(process.env.AI_WORKSPACE_HUB_INTERNAL_URL) || + optionalString(process.env.NDC_AI_WORKSPACE_HUB_HTTP_URL) || + optionalString(process.env.AI_WORKSPACE_HUB_HTTP_URL) || + httpUrlFromWebSocketUrl(hubWebSocketUrl); + const explicitHubAccessToken = + optionalString(process.env.AI_WORKSPACE_HUB_TOKEN) || + optionalString(process.env.NDC_AI_WORKSPACE_HUB_TOKEN) || + ""; + const sharedInternalAccessToken = optionalString(process.env.NODEDC_INTERNAL_ACCESS_TOKEN) || ""; return { port: Number(process.env.PORT || process.env.AI_WORKSPACE_ASSISTANT_PORT || "18082"), databaseUrl, databasePoolSize: Number(process.env.AI_WORKSPACE_ASSISTANT_DATABASE_POOL_SIZE || "10"), - hubWebSocketUrl: - process.env.AI_WORKSPACE_HUB_PUBLIC_URL || - process.env.NDC_AI_WORKSPACE_HUB_URL || - process.env.AI_WORKSPACE_HUB_URL || - "wss://ai-hub.nodedc.ru/api/ai-workspace/hub", + hubWebSocketUrl, + hubInternalHttpUrl: hubInternalHttpUrl.replace(/\/+$/, ""), + hubInternalAccessToken: + explicitHubAccessToken || (isDeployedPublicHubUrl(hubInternalHttpUrl) ? "" : sharedInternalAccessToken), hubFallbackWebSocketUrls: String( process.env.AI_WORKSPACE_HUB_FALLBACK_URLS || process.env.NDC_AI_WORKSPACE_HUB_FALLBACK_URLS || @@ -979,11 +2605,11 @@ function readConfig() { .split(/[\n,;]+/) .map((item) => item.trim()) .filter(Boolean), - internalAccessToken: - process.env.AI_WORKSPACE_ASSISTANT_TOKEN || - process.env.NODEDC_INTERNAL_ACCESS_TOKEN || - process.env.NODEDC_PLATFORM_SERVICE_TOKEN || - "", + internalAccessTokens: uniqueStrings([ + process.env.AI_WORKSPACE_ASSISTANT_TOKEN, + process.env.NODEDC_INTERNAL_ACCESS_TOKEN, + process.env.NODEDC_PLATFORM_SERVICE_TOKEN, + ]), }; } diff --git a/services/ai-workspace-assistant/src/templates/install-windows.ps1 b/services/ai-workspace-assistant/src/templates/install-windows.ps1 index 81ec5ce..485507c 100644 --- a/services/ai-workspace-assistant/src/templates/install-windows.ps1 +++ b/services/ai-workspace-assistant/src/templates/install-windows.ps1 @@ -11,10 +11,14 @@ param( [string]$HubFallbackUrls = "", [string]$PairingCode = "", [string]$MachineName = "", + [string]$OpsMcpUrl = "", + [string]$OpsMcpToken = "", + [string]$OpsMcpServerName = "nodedc_tasker", + [string]$AppMcpServersJson = "", [switch]$NoAutostart, [switch]$NoFirewall, [switch]$NoCodexLogin, - [switch]$EnableWatchdog, + [switch]$EnableWatchdog = $true, [switch]$Confirmed ) @@ -215,6 +219,10 @@ function Request-Elevation { if ($HubFallbackUrls) { $args += @('-HubFallbackUrls', "`"$HubFallbackUrls`"") } if ($PairingCode) { $args += @('-PairingCode', "`"$PairingCode`"") } if ($MachineName) { $args += @('-MachineName', "`"$MachineName`"") } + if ($OpsMcpUrl) { $args += @('-OpsMcpUrl', "`"$OpsMcpUrl`"") } + if ($OpsMcpToken) { $args += @('-OpsMcpToken', "`"$OpsMcpToken`"") } + if ($OpsMcpServerName) { $args += @('-OpsMcpServerName', "`"$OpsMcpServerName`"") } + if ($AppMcpServersJson) { $args += @('-AppMcpServersJson', (ConvertTo-PsSingleQuoted $AppMcpServersJson)) } Write-Host 'Opening an Administrator PowerShell window. Approve the Windows UAC prompt and continue there.' -ForegroundColor Yellow Start-Process -FilePath 'powershell.exe' -ArgumentList ($args -join ' ') -Verb RunAs -Wait @@ -506,6 +514,185 @@ function Repair-CodexMcpConfig { } } +function ConvertTo-TomlString { + param([string]$Value) + $escaped = ([string]$Value).Replace('\', '\\').Replace('"', '\"').Replace("`r", '\r').Replace("`n", '\n') + return '"' + $escaped + '"' +} + +function ConvertTo-TomlKey { + param([string]$Key) + $text = ([string]$Key).Trim() + if ($text -match '^[A-Za-z0-9_-]+$') { return $text } + return ConvertTo-TomlString $text +} + +function Get-SafeMcpServerName { + param([string]$ServerName) + $candidate = ([string]$ServerName).Trim() + if ($candidate -match '^[A-Za-z0-9_-]+$') { return $candidate } + return 'nodedc_tasker' +} + +function Remove-CodexMcpServerSections { + param( + [string]$Raw, + [string]$ServerName + ) + if (-not $Raw) { return "" } + + $mainHeader = "[mcp_servers.$ServerName]" + $headersHeader = "[mcp_servers.$ServerName.headers]" + $httpHeadersHeader = "[mcp_servers.$ServerName.http_headers]" + $lines = $Raw -split '\r?\n' + $result = New-Object System.Collections.Generic.List[string] + $skip = $false + $inMcpServers = $false + + foreach ($line in $lines) { + $headerKey = Normalize-TomlHeader $line + if ($headerKey.StartsWith('[')) { + $skip = ($headerKey -eq $mainHeader -or $headerKey -eq $headersHeader -or $headerKey -eq $httpHeadersHeader) + $inMcpServers = ($headerKey -eq '[mcp_servers]') + if ($skip) { continue } + } + + $activeLine = Remove-TomlComment $line + if ($inMcpServers -and $activeLine -match ("^\s*" + [regex]::Escape($ServerName) + "\s*=")) { + continue + } + if (-not $skip) { + $result.Add($line) | Out-Null + } + } + + return ($result -join ([Environment]::NewLine)).TrimEnd() +} + +function Ensure-McpServerConfig { + param( + [string]$ServerName, + [string]$McpUrl, + [object]$Headers, + [bool]$Required = $false, + [int]$StartupTimeoutSec = 20, + [int]$ToolTimeoutSec = 60, + [string]$Label = "AI Workspace" + ) + + $serverName = Get-SafeMcpServerName $ServerName + $mcpUrl = ([string]$McpUrl).Trim() + if (-not $serverName -or -not $mcpUrl) { return } + + $configDir = Join-Path $env:USERPROFILE '.codex' + $configPath = Join-Path $configDir 'config.toml' + New-Item -ItemType Directory -Force -Path $configDir | Out-Null + + $raw = "" + if (Test-Path $configPath) { + try { + $raw = Get-Content -Path $configPath -Raw -Encoding UTF8 + } catch { + Write-Warn "Could not read Codex config for MCP setup: $($_.Exception.Message)" + return + } + } + + $next = Remove-CodexMcpServerSections -Raw $raw -ServerName $serverName + $requiredValue = if ($Required) { "true" } else { "false" } + $startupTimeout = [Math]::Max(1, [int]$StartupTimeoutSec) + $toolTimeout = [Math]::Max(1, [int]$ToolTimeoutSec) + $sectionLines = New-Object System.Collections.Generic.List[string] + $sectionLines.Add("") | Out-Null + $sectionLines.Add("[mcp_servers.$serverName]") | Out-Null + $sectionLines.Add("url = $(ConvertTo-TomlString $mcpUrl)") | Out-Null + $sectionLines.Add("enabled = true") | Out-Null + $sectionLines.Add("required = $requiredValue") | Out-Null + $sectionLines.Add("startup_timeout_sec = $startupTimeout") | Out-Null + $sectionLines.Add("tool_timeout_sec = $toolTimeout") | Out-Null + + $headerLines = New-Object System.Collections.Generic.List[string] + if ($Headers) { + foreach ($property in $Headers.PSObject.Properties) { + $headerName = ([string]$property.Name).Trim() + $headerValue = ([string]$property.Value).Trim() + if (-not $headerName -or -not $headerValue) { continue } + $headerLines.Add("$(ConvertTo-TomlKey $headerName) = $(ConvertTo-TomlString $headerValue)") | Out-Null + } + } + if ($headerLines.Count -gt 0) { + $sectionLines.Add("") | Out-Null + $sectionLines.Add("[mcp_servers.$serverName.http_headers]") | Out-Null + foreach ($line in $headerLines) { $sectionLines.Add($line) | Out-Null } + } + + $section = $sectionLines -join ([Environment]::NewLine) + $next = ($next + $section).TrimStart() + + try { + if (Test-Path $configPath) { + $backupPath = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')" + Copy-Item -Path $configPath -Destination $backupPath -Force + Write-Ok "Codex config backup created: $backupPath" + } + Set-Content -Path $configPath -Value $next -Encoding UTF8 + Write-Ok "Codex MCP configured for $Label ($serverName)." + } catch { + Write-Warn "Could not update Codex config for MCP server $serverName`: $($_.Exception.Message)" + } +} + +function Ensure-AppMcpConfigs { + $rawJson = ([string]$AppMcpServersJson).Trim() + if (-not $rawJson) { return } + try { + $servers = $rawJson | ConvertFrom-Json + } catch { + Write-Warn "Could not parse AI Workspace app MCP manifest: $($_.Exception.Message)" + return + } + if ($null -eq $servers) { return } + if ($servers -isnot [System.Array]) { $servers = @($servers) } + + foreach ($server in $servers) { + if ($null -eq $server) { continue } + $serverName = [string]$server.serverName + $mcpUrl = [string]$server.url + $headers = $server.httpHeaders + if ($null -eq $headers) { $headers = $server.headers } + $label = [string]$server.appTitle + if (-not $label) { $label = [string]$server.appId } + if (-not $label) { $label = "AI Workspace" } + $startupTimeout = 20 + $toolTimeout = 60 + try { if ($server.startupTimeoutSec) { $startupTimeout = [int]$server.startupTimeoutSec } } catch {} + try { if ($server.toolTimeoutSec) { $toolTimeout = [int]$server.toolTimeoutSec } } catch {} + Ensure-McpServerConfig ` + -ServerName $serverName ` + -McpUrl $mcpUrl ` + -Headers $headers ` + -Required ($server.required -eq $true) ` + -StartupTimeoutSec $startupTimeout ` + -ToolTimeoutSec $toolTimeout ` + -Label $label + } +} + +function Ensure-OpsMcpConfig { + $mcpUrl = ([string]$OpsMcpUrl).Trim() + $mcpToken = ([string]$OpsMcpToken).Trim() + if (-not $mcpUrl -or -not $mcpToken) { return } + + $serverName = Get-SafeMcpServerName $OpsMcpServerName + $authorization = "Bearer $mcpToken" + $headers = [pscustomobject]@{ + Authorization = $authorization + Accept = "application/json" + "MCP-Protocol-Version" = "2025-06-18" + } + Ensure-McpServerConfig -ServerName $serverName -McpUrl $mcpUrl -Headers $headers -Required $false -StartupTimeoutSec 20 -ToolTimeoutSec 60 -Label "AI Workspace Ops" +} + function Get-WorkspacePath { $workspacePath = Get-DefaultWorkspaceCandidate Write-Ok "Codex workspace: $workspacePath" @@ -863,37 +1050,97 @@ function buildPrompt(payload) { const userMessage = String(payload?.message || '').trim() const history = payload?.resume ? [] : normalizeHistory(payload?.history || []) const isNdcAgentCore = String(context.modeId || '').trim() === 'ndc-agent-core' + const isOpsMode = String(context.modeId || '').trim() === 'ops' + const guardInstruction = String(context.guardInstruction || payload?.guardInstruction || '').trim() + const missingContext = Array.isArray(context.missingContext) + ? context.missingContext.map((item) => String(item || '').trim()).filter(Boolean) + : [] + const contextReady = context.contextReady !== false && missingContext.length === 0 + const engineContext = context.engineContext && typeof context.engineContext === 'object' + ? context.engineContext + : context.contexts?.engine && typeof context.contexts.engine === 'object' + ? context.contexts.engine + : {} + const opsContext = context.opsContext && typeof context.opsContext === 'object' + ? context.opsContext + : context.contexts?.ops && typeof context.contexts.ops === 'object' + ? context.contexts.ops + : {} const ndcAgentMcpApiBaseUrl = deriveNdcAgentMcpApiBaseUrl(context) const lines = [ 'You are connected to NODE.DC AI Workspace through AI Workspace Bridge.', '', 'Context:', + `- active surface: ${context.surface || 'unknown'}`, + `- source surface: ${context.sourceSurface || context.surface || 'unknown'}`, `- mode: ${context.modeTitle || context.modeId || 'unknown'}`, `- workflow: ${context.workflowTitle || context.workflowId || 'unknown'}`, `- agent node: ${context.agentNodeTitle || context.agentNodeId || 'unknown'}`, `- role: ${context.roleTitle || context.roleId || 'unknown'}`, `- workspace: ${context.workspacePath || payload?.workspacePath || CODEX_CWD}`, + `- context ready: ${contextReady ? 'yes' : 'no'}`, + ...missingContext.length ? [`- missing context: ${missingContext.join(', ')}`] : [], + `- access mode: ${context.accessMode || 'chat'}`, + '', + 'Cross-platform contexts:', + `- Engine workflow: ${engineContext.workflowTitle || engineContext.workflowId || 'not selected'}`, + `- Engine agent node: ${engineContext.agentNodeTitle || engineContext.agentNodeId || 'not selected'}`, + `- Engine role: ${engineContext.roleTitle || engineContext.roleId || 'not selected'}`, + `- Ops workspace: ${opsContext.opsWorkspaceTitle || opsContext.opsWorkspaceSlug || opsContext.opsWorkspaceId || 'not selected'}`, + `- Ops project: ${opsContext.opsProjectTitle || opsContext.opsProjectIdentifier || opsContext.opsProjectId || 'not selected'}`, + '- the active/source surface is where the user opened the assistant; it is not a write boundary by itself', + '- choose the write target from the selected per-app contexts and the user request; cross-app work is allowed when the relevant context is selected', + '- do not refuse Engine work only because the source surface is Ops, and do not refuse Ops work only because the mode is NDC Agent Core', '- use the user language for the final answer and public progress/reasoning summaries; if the user writes in Russian, write them in Russian', + '- never mention internal n8n/N8N names, endpoints, environment variables, schemas, or runtime identifiers in public answers; call the product and workflow runtime NDC', '- while working, provide concise public progress/reasoning summaries when the Codex runtime exposes them', '- public progress/reasoning summaries should say what you are checking, reading, comparing, or deciding', '- keep public progress/reasoning summaries user-facing; do not include raw command lines, JSON, hidden chain-of-thought, or bridge transport details', ...isNdcAgentCore ? [ '', 'NDC Agent Core mode contract:', - '- Treat the selected agent node as the only writable target.', - '- Do not write directly to the NDC core runtime and do not modify deployed runtime workflows by hand.', - '- The Engine second-level workflow file is the source of truth for edits.', - `- Engine API base for this target: ${ndcAgentMcpApiBaseUrl}`, - `- Selected workflowId: ${context.workflowId || 'unknown'}`, - `- 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.', - '- Do not use direct n8n/NDC core 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.', - '- Supported operations: addNode, upsertNode, updateNode, removeNode, moveNode, addConnection, removeConnection.', - '- 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.', + ...!contextReady ? [ + '- The NDC Agent Core context is incomplete. Chat, explanations, reading, and Ops work are still allowed when the required target for that work is selected.', + '- Do not claim that an Engine agent node is selected when it is missing.', + '- Do not create, edit, patch, deploy, or write Engine workflow graph changes until the missing Engine agent node is selected.', + '- If the user asks for Engine development or graph edits, explain briefly that selecting an Engine agent node is required for that specific write action.', + ] : [ + '- Treat the selected Engine agent node as the only writable Engine workflow target.', + '- Do not write directly to the NDC core runtime and do not modify deployed runtime workflows by hand.', + '- The Engine second-level workflow file is the source of truth for edits.', + `- Engine API base for this target: ${ndcAgentMcpApiBaseUrl}`, + `- Selected workflowId: ${context.workflowId || 'unknown'}`, + `- 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.', + '- 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.', + '- Do not expose internal vendor names, raw implementation field names, backend schema routes, or internal node class names in public answers.', + '- Supported operations: addNode, upsertNode, updateNode, removeNode, moveNode, addConnection, removeConnection.', + '- 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.', + ], + ] : [], + ...(isOpsMode || opsContext.opsWorkspaceSlug || opsContext.opsProjectId ? [ + '', + 'NODE.DC Ops mode contract:', + '- Use Ops only inside the selected Ops workspace and project.', + `- Selected Ops workspace slug: ${opsContext.opsWorkspaceSlug || opsContext.opsWorkspaceId || 'unknown'}`, + `- 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.', + '- 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.', + '- 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.', + ] : []), + ...guardInstruction ? [ + '', + 'System context guard:', + guardInstruction, ] : [], '', ...history.length ? [ @@ -1354,6 +1601,99 @@ function stripTopLevelTomlKeys(raw, keyNames) { return result.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() } +function extractTomlSection(raw, sectionName) { + const section = String(sectionName || '').trim() + if (!section) return '' + const headerKey = `[${section}]` + const lines = String(raw || '').split(/\r?\n/) + const result = [] + let collecting = false + for (const line of lines) { + const header = normalizedTomlHeader(line) + if (header) { + if (header === headerKey) { + collecting = true + result.push(line) + continue + } + if (collecting) break + } + if (collecting) result.push(line) + } + return result.join('\n').trim() +} + +function normalizeMcpServerSection(section, serverName) { + const lines = String(section || '').split(/\r?\n/) + const result = [] + let hasEnabled = false + let hasRequired = false + for (const line of lines) { + const header = normalizedTomlHeader(line) + if (header) { + result.push(`[mcp_servers.${serverName}]`) + continue + } + const activeLine = stripTomlComment(line) + if (/^\s*transport\s*=/.test(activeLine)) continue + if (/^\s*required\s*=/.test(activeLine)) { + result.push('required = false') + hasRequired = true + continue + } + if (/^\s*enabled\s*=/.test(activeLine)) { + result.push('enabled = true') + hasEnabled = true + continue + } + result.push(line) + } + if (!hasEnabled) result.push('enabled = true') + if (!hasRequired) result.push('required = false') + return result.join('\n').trim() +} + +function normalizeMcpHeadersSection(section, serverName) { + if (!section) return '' + const lines = String(section || '').split(/\r?\n/) + const result = [] + for (const line of lines) { + const header = normalizedTomlHeader(line) + if (header) { + result.push(`[mcp_servers.${serverName}.http_headers]`) + continue + } + result.push(line) + } + return result.join('\n').trim() +} + +function extractOptionalOpsMcpConfig(raw) { + const serverNames = ['nodedc-ops-agent', 'nodedc_tasker'] + const configs = [] + for (const serverName of serverNames) { + const baseSection = extractTomlSection(raw, `mcp_servers.${serverName}`) + if (!baseSection) continue + const headersSection = extractTomlSection(raw, `mcp_servers.${serverName}.http_headers`) + || extractTomlSection(raw, `mcp_servers.${serverName}.headers`) + const normalized = [ + normalizeMcpServerSection(baseSection, serverName), + normalizeMcpHeadersSection(headersSection, serverName), + ].filter(Boolean).join('\n\n').trim() + if (normalized) configs.push(normalized) + } + return configs.join('\n\n').trim() +} + +async function readOptionalOpsMcpConfig() { + try { + const raw = await fs.readFile(path.join(CODEX_HOME, 'config.toml'), 'utf8') + return extractOptionalOpsMcpConfig(raw) + } catch { + return '' + } +} + async function copyIfExists(from, to) { try { await fs.copyFile(from, to) @@ -1380,6 +1720,7 @@ function ndcAgentMcpEnvConfig(mcpContext, cwd) { async function prepareNdcAgentCodexHome(mcpContext = {}, cwd = CODEX_CWD) { await fs.mkdir(NDC_AGENT_CODEX_HOME, { recursive: true }) await copyIfExists(path.join(CODEX_HOME, 'auth.json'), path.join(NDC_AGENT_CODEX_HOME, 'auth.json')) + const opsMcpConfig = await readOptionalOpsMcpConfig() const runtimeConfig = [ 'approval_policy = "never"', 'sandbox_mode = "danger-full-access"', @@ -1391,7 +1732,12 @@ async function prepareNdcAgentCodexHome(mcpContext = {}, cwd = CODEX_CWD) { 'startup_timeout_sec = 20', 'tool_timeout_sec = 120', ].join('\n') - await fs.writeFile(path.join(NDC_AGENT_CODEX_HOME, 'config.toml'), `${runtimeConfig}\n\n${ndcConfig}\n${ndcAgentMcpEnvConfig(mcpContext, cwd)}\n`, 'utf8') + const config = [ + runtimeConfig, + `${ndcConfig}\n${ndcAgentMcpEnvConfig(mcpContext, cwd)}`, + opsMcpConfig, + ].filter(Boolean).join('\n\n') + await fs.writeFile(path.join(NDC_AGENT_CODEX_HOME, 'config.toml'), `${config}\n`, 'utf8') return NDC_AGENT_CODEX_HOME } @@ -1600,13 +1946,14 @@ function createActiveMirror(command, payload = {}, meta = {}) { const context = payload?.context && typeof payload.context === 'object' ? payload.context : {} if (!ACTIVE_MIRROR_ENABLED || command !== 'message' || context.remoteControlMode !== 'active') return null const cwd = String(meta.cwd || CODEX_CWD) + const displayMessage = String(payload.displayMessage || payload.publicUserMessage || payload.message || '') const { mirrorDir, currentFile, eventsFile } = activeMirrorPathsForCwd(cwd) const startedAt = new Date().toISOString() const state = { requestId: String(meta.requestId || payload.threadId || `local-${Date.now()}`), threadId: String(payload.threadId || ''), threadTitle: String(payload.threadTitle || ''), - userMessage: String(payload.message || ''), + userMessage: displayMessage, modeId: String(context.modeId || ''), modeTitle: String(context.modeTitle || ''), machineName: MACHINE_NAME, @@ -2620,10 +2967,16 @@ function engineApiBaseCandidates() { ...splitList(process.env.AI_BRIDGE_HUB_URLS).map(hubOriginToApiBase), ] const envBase = cleanString(process.env.NDC_AGENT_MCP_API_BASE_URL, 1000).replace(/\/+$/, '') - const ordered = isLoopbackUrl(explicit) && hubBases.some(Boolean) - ? [...hubBases, envBase, explicit, DEFAULT_API_BASE] - : [explicit, envBase, ...hubBases, DEFAULT_API_BASE] - return uniqueStrings(ordered) + if (explicit && !isLoopbackUrl(explicit)) { + return uniqueStrings([explicit, envBase]) + } + if (envBase && !isLoopbackUrl(envBase)) { + return uniqueStrings([envBase, explicit]) + } + if (hubBases.some(Boolean)) { + return uniqueStrings([...hubBases, envBase, explicit]) + } + return uniqueStrings([explicit, envBase, DEFAULT_API_BASE]) } function engineApiUrls(pathname) { @@ -2886,20 +3239,11 @@ async function handleGetContext() { async function handleGetSubworkflow(args) { const target = targetFromArgs(args) const q = new URLSearchParams(target) - try { - const result = await engineFetch(`/subworkflow?${q.toString()}`) - return { - ok: true, - ...(result && typeof result === 'object' ? result : {}), - graph: normalizeGraphResult(result?.graph, target.workflowId, target.nodeId), - } - } catch (error) { - if (Number(error?.status || 0) !== 404 && !/not_found/.test(String(error?.message || ''))) throw error - return { - ok: true, - graph: normalizeGraphResult(null, target.workflowId, target.nodeId), - missing: true, - } + const result = await engineFetch(`/subworkflow?${q.toString()}`) + return { + ok: true, + ...(result && typeof result === 'object' ? result : {}), + graph: normalizeGraphResult(result?.graph, target.workflowId, target.nodeId), } } @@ -3767,6 +4111,8 @@ function Main { Ensure-Codex Ensure-CodexLogin Repair-CodexMcpConfig + Ensure-AppMcpConfigs + if (-not $AppMcpServersJson) { Ensure-OpsMcpConfig } Stop-BridgeNow $script:Port = Resolve-BridgePort $files = Write-WorkerFiles -Root $InstallRoot -WorkspacePath $workspacePath diff --git a/services/ai-workspace-hub/src/server.mjs b/services/ai-workspace-hub/src/server.mjs index 624528e..c1e819c 100644 --- a/services/ai-workspace-hub/src/server.mjs +++ b/services/ai-workspace-hub/src/server.mjs @@ -307,11 +307,12 @@ function readAgentEvents(pairingCodeRaw, options = {}) { function requestMetaFromPayload(command, payload = {}) { const context = payload?.context && typeof payload.context === "object" ? payload.context : {}; + const publicUserMessage = payload?.displayMessage || payload?.publicUserMessage || payload?.message; return { command: cleanString(command, 80), threadId: cleanString(payload?.threadId, 160), threadTitle: cleanString(payload?.threadTitle, 240), - userMessage: cleanString(payload?.message, 12000), + userMessage: cleanString(publicUserMessage, 12000), remoteControlMode: cleanString(context.remoteControlMode, 40), }; } From 6244b44c6eeb76a0589c92a541612f71668218b7 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 13 Jun 2026 17:30:48 +0300 Subject: [PATCH 7/7] Add dynamic AI Workspace run profiles --- docs/AI_WORKSPACE_CONFIG_CONTRACT.md | 54 ++ docs/DEPLOYMENT_LOCAL.md | 34 ++ infra/.env.example | 7 +- .../check-ai-workspace-config-contract.sh | 146 ++++++ .../check-ai-workspace-release-gates.sh | 123 +++++ infra/scripts/check-ai-workspace-topology.sh | 194 +++++++ infra/scripts/init-dev-env.sh | 8 +- infra/synology/.env.synology.example | 3 + infra/synology/README.md | 10 + infra/synology/apply-current-runtime.sh | 4 + infra/synology/backup-current.sh | 76 ++- .../synology/check-ai-workspace-apply-plan.sh | 190 +++++++ infra/synology/deploy-current.sh | 31 +- infra/synology/prepare-ai-workspace-env.sh | 98 ++++ infra/synology/verify-current-runtime.sh | 4 + services/ai-workspace-assistant/README.md | 43 ++ .../ai-workspace-assistant/TEST_MATRIX.md | 178 +++++++ services/ai-workspace-assistant/package.json | 3 +- .../src/scripts/smoke-run-profile.mjs | 347 +++++++++++++ .../ai-workspace-assistant/src/server.mjs | 483 ++++++++++++++++++ .../src/templates/install-windows.ps1 | 195 ++++++- 21 files changed, 2179 insertions(+), 52 deletions(-) create mode 100644 docs/AI_WORKSPACE_CONFIG_CONTRACT.md create mode 100755 infra/scripts/check-ai-workspace-config-contract.sh create mode 100755 infra/scripts/check-ai-workspace-release-gates.sh create mode 100755 infra/scripts/check-ai-workspace-topology.sh create mode 100755 infra/synology/check-ai-workspace-apply-plan.sh create mode 100755 infra/synology/prepare-ai-workspace-env.sh create mode 100644 services/ai-workspace-assistant/TEST_MATRIX.md create mode 100644 services/ai-workspace-assistant/src/scripts/smoke-run-profile.mjs diff --git a/docs/AI_WORKSPACE_CONFIG_CONTRACT.md b/docs/AI_WORKSPACE_CONFIG_CONTRACT.md new file mode 100644 index 0000000..002feef --- /dev/null +++ b/docs/AI_WORKSPACE_CONFIG_CONTRACT.md @@ -0,0 +1,54 @@ +# AI Workspace Config Contract + +This contract keeps topology differences in env/config instead of product-code branches. + +## URL Classes + +- Worker-facing URL: returned to or used by the Codex worker. It must be reachable from the machine that executes Codex. +- Backend-internal URL: used by app backends, Platform Assistant, Hub, Gateway, and service-to-service calls. +- Browser URL: used by user-facing UI. +- Downstream app URL: used by an adapter service to mutate/read its owned app backend. + +Do not mix these classes. A URL reachable from the Mac browser is not automatically reachable from a remote Codex worker. + +## Current Profiles + +`local/hybrid-prod-bridge`: + +- 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` +- 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. + +`synology/prod-public`: + +- Browser Launcher: `https://hub.nodedc.ru` +- Browser Ops: `https://ops.nodedc.ru` +- Worker-facing Hub: `wss://ai-hub.nodedc.ru/api/ai-workspace/hub` +- Platform Assistant internal: `http://ai-workspace-assistant:18082` +- Platform to Ops entitlement: `http://172.22.0.222:18190/api/internal/v1/ai-workspace/entitlements` +- Ops Gateway worker-facing MCP: `https://ops-agents.nodedc.ru/mcp` +- Ops Gateway downstream Tasker: `http://172.22.0.222:18090` +- This is the preferred controlled runtime e2e target. + +`tailscale/tunnel-local-e2e`: + +- Allowed only as an explicit profile. +- All worker-facing URLs must be reachable from the executing Codex worker through Tailscale or a named tunnel. +- No product code should auto-fallback from localhost to Tailscale to public domains. + +## Required Rule + +New apps must join AI Workspace through app manifests/config/adapters: + +- app id and surface id; +- context schema; +- entitlement adapter URL/token; +- MCP/tool pack URL and worker-facing public URL; +- smoke test contract; +- owner repo/service. + +Do not add app-specific address logic to the agent installer, Engine, Ops, or Platform Assistant runtime flow. diff --git a/docs/DEPLOYMENT_LOCAL.md b/docs/DEPLOYMENT_LOCAL.md index 5d6ba5f..60d7712 100644 --- a/docs/DEPLOYMENT_LOCAL.md +++ b/docs/DEPLOYMENT_LOCAL.md @@ -44,6 +44,40 @@ Source fork: /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src ``` +## AI Workspace Topology + +Локальная доступность UI не равна честному AI Workspace e2e. Перед интерпретацией результата запускайте: + +```bash +cd /Users/dcconstructions/Downloads/mnt/NODEDC/platform +infra/scripts/check-ai-workspace-topology.sh +``` + +Скрипт ничего не прокидывает и не меняет. Он только классифицирует текущий режим: + +- `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. +- `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`, а не доказательством локальной записи. + +Перед controlled Synology apply запускайте общий predeploy gate: + +```bash +infra/scripts/check-ai-workspace-release-gates.sh +``` + +Он не деплоит, не перезапускает контейнеры и не меняет `.env`. Скрипт только выполняет meaningful проверки до runtime e2e: topology classification, Platform contract smoke, Ops Gateway static/build/smoke, Engine static check и diff hygiene. + +Адресная модель описана в `docs/AI_WORKSPACE_CONFIG_CONTRACT.md`. При изменении env examples, compose или AI Workspace URL запускайте: + +```bash +infra/scripts/check-ai-workspace-config-contract.sh +``` + ## Target local flow Первый local infra milestone: diff --git a/infra/.env.example b/infra/.env.example index af88641..75c50fb 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -59,9 +59,14 @@ AI_WORKSPACE_PG_USER=nodedc_ai_workspace AI_WORKSPACE_PG_PASS=change-me-generate-with-infra-scripts-init-dev-env AI_WORKSPACE_ASSISTANT_TOKEN=change-me-generate-with-infra-scripts-init-dev-env 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 Hub for downloaded Codex workers. -# Default local development uses the deployed relay. Override these only for offline Hub development. +# 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. 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/scripts/check-ai-workspace-config-contract.sh b/infra/scripts/check-ai-workspace-config-contract.sh new file mode 100755 index 0000000..8dc3908 --- /dev/null +++ b/infra/scripts/check-ai-workspace-config-contract.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +PLATFORM_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd) +OPS_GATEWAY_REPO="${OPS_GATEWAY_REPO:-$PLATFORM_ROOT/../../data/NODEDC_TASKMANAGER_CODEXAPI}" +ENGINE_REPO="${ENGINE_REPO:-$PLATFORM_ROOT/../NODEDC_ENGINE_INFRA}" +PLATFORM_ENV="${PLATFORM_ENV:-$PLATFORM_ROOT/infra/.env}" + +passed=0 +failed=0 +warnings=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 +} + +warn() { + warnings=$((warnings + 1)) + printf 'WARN %s\n' "$1" >&2 +} + +require_file() { + file="$1" + label="$2" + if [ -f "$file" ]; then + pass "$label" + else + fail "$label (missing file: $file)" + fi +} + +require_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 + pass "$label" + else + fail "$label (missing: $needle)" + fi +} + +read_env() { + file="$1" + key="$2" + if [ ! -f "$file" ]; then + return 0 + fi + 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_env_value() { + file="$1" + key="$2" + expected="$3" + label="$4" + actual=$(read_env "$file" "$key" || true) + if [ "$actual" = "$expected" ]; then + pass "$label" + else + fail "$label (expected $key=$expected, got ${actual:-})" + fi +} + +section "Required files" +require_file "$PLATFORM_ROOT/infra/.env.example" "Platform local env example exists" +require_file "$PLATFORM_ROOT/infra/synology/.env.synology.example" "Platform Synology env example exists" +require_file "$PLATFORM_ROOT/infra/docker-compose.dev.yml" "Platform local compose exists" +require_file "$PLATFORM_ROOT/infra/synology/docker-compose.platform-http.yml" "Platform Synology compose 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" +require_file "$OPS_GATEWAY_REPO/docker-compose.synology.yml" "Ops Gateway Synology compose exists" +require_file "$ENGINE_REPO/docker-compose.yml" "Engine compose exists" + +section "Platform local contract" +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_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" + +section "Platform Synology contract" +require_env_value "$PLATFORM_ROOT/infra/synology/.env.synology.example" "NODEDC_AI_WORKSPACE_ASSISTANT_URL" "http://ai-workspace-assistant:18082" "Synology Platform exposes Assistant to app services on compose network" +require_env_value "$PLATFORM_ROOT/infra/synology/.env.synology.example" "AI_WORKSPACE_OPS_ENTITLEMENT_URL" "http://172.22.0.222:18190/api/internal/v1/ai-workspace/entitlements" "Synology Platform points Ops entitlement to Synology Gateway" +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" + +section "Ops Gateway contract" +require_env_value "$OPS_GATEWAY_REPO/.env.example" "NODEDC_AGENT_GATEWAY_PUBLIC_URL" "https://ops-agents.nodedc.ru" "Ops Gateway local example keeps worker-facing MCP URL explicit" +require_env_value "$OPS_GATEWAY_REPO/.env.example" "NODEDC_TASKER_INTERNAL_URL" "http://task.local.nodedc" "Ops Gateway local example points downstream to local Tasker" +require_env_value "$OPS_GATEWAY_REPO/.env.synology.example" "NODEDC_AGENT_GATEWAY_PUBLIC_URL" "https://ops-agents.nodedc.ru" "Ops Gateway Synology public MCP URL is public relay" +require_env_value "$OPS_GATEWAY_REPO/.env.synology.example" "NODEDC_TASKER_INTERNAL_URL" "http://172.22.0.222:18090" "Ops Gateway Synology downstream points to Synology Tasker" +require_contains "$OPS_GATEWAY_REPO/docker-compose.synology.yml" '${HOST_BIND:-172.22.0.222}:${HOST_PORT:-18190}:${PORT:-4100}' "Ops Gateway Synology compose binds expected internal port" +require_contains "$OPS_GATEWAY_REPO/README.md" "The response uses the AI Workspace adapter contract" "Ops Gateway README documents AI Workspace adapter contract" + +section "Engine contract" +require_contains "$ENGINE_REPO/docker-compose.yml" 'NODEDC_AI_WORKSPACE_ASSISTANT_URL: ${NODEDC_AI_WORKSPACE_ASSISTANT_URL:-http://ai-workspace-assistant:18082}' "Engine compose accepts configurable Assistant URL" +require_contains "$ENGINE_REPO/docker-compose.yml" 'NDC_AI_WORKSPACE_HUB_URL: ${NDC_AI_WORKSPACE_HUB_URL:-wss://ai-hub.nodedc.ru/api/ai-workspace/hub}' "Engine compose accepts configurable worker-facing Hub URL" +require_contains "$ENGINE_REPO/docker-compose.yml" 'NDC_AI_WORKSPACE_HUB_HTTP_URL: ${NDC_AI_WORKSPACE_HUB_HTTP_URL:-https://ai-hub.nodedc.ru}' "Engine compose accepts configurable Hub HTTP URL" +require_contains "$ENGINE_REPO/nodedc-source/server/aiWorkspace/assistantRegistryClient.js" "'X-NODEDC-User-Role': role" "Engine forwards user role to Assistant" +require_contains "$ENGINE_REPO/nodedc-source/server/aiWorkspace/assistantRegistryClient.js" "'X-NODEDC-User-Groups': groups.join(',')" "Engine forwards user groups to Assistant" + +section "Current local env warnings" +if [ -f "$PLATFORM_ENV" ]; then + local_ops_entitlement=$(read_env "$PLATFORM_ENV" AI_WORKSPACE_OPS_ENTITLEMENT_URL || true) + if [ -z "$local_ops_entitlement" ]; then + warn "Current Platform env has no AI_WORKSPACE_OPS_ENTITLEMENT_URL; current Mac remains hybrid/contract-only until env is deliberately updated." + else + pass "Current Platform env has AI_WORKSPACE_OPS_ENTITLEMENT_URL" + fi +else + warn "Current Platform env not found: $PLATFORM_ENV" +fi + +section "Summary" +printf 'passed=%s failed=%s warnings=%s\n' "$passed" "$failed" "$warnings" + +if [ "$failed" -ne 0 ]; then + exit 1 +fi diff --git a/infra/scripts/check-ai-workspace-release-gates.sh b/infra/scripts/check-ai-workspace-release-gates.sh new file mode 100755 index 0000000..02f8166 --- /dev/null +++ b/infra/scripts/check-ai-workspace-release-gates.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +PLATFORM_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd) +AI_ASSISTANT_DIR="$PLATFORM_ROOT/services/ai-workspace-assistant" + +OPS_GATEWAY_REPO="${OPS_GATEWAY_REPO:-$PLATFORM_ROOT/../../data/NODEDC_TASKMANAGER_CODEXAPI}" +ENGINE_REPO="${ENGINE_REPO:-$PLATFORM_ROOT/../NODEDC_ENGINE_INFRA}" + +RUN_GATEWAY_SMOKE="${RUN_GATEWAY_SMOKE:-1}" +GATEWAY_DATABASE_URL="${GATEWAY_DATABASE_URL:-postgres://nodedc_agent_gateway:replace-with-local-postgres-password@localhost:54100/nodedc_agent_gateway}" +GATEWAY_INTERNAL_TOKEN="${GATEWAY_INTERNAL_TOKEN:-replace-with-gateway-internal-token}" +GATEWAY_RUN_TOKEN_TTL_SECONDS="${GATEWAY_RUN_TOKEN_TTL_SECONDS:-43200}" + +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_in() { + dir="$1" + label="$2" + command="$3" + + if [ ! -d "$dir" ]; then + fail "$label (missing directory: $dir)" + return 0 + fi + + 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 +} + +section "AI Workspace topology classification" +run_in "$PLATFORM_ROOT" \ + "Classify current topology" \ + "infra/scripts/check-ai-workspace-topology.sh" + +section "Platform static and contract gates" +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" +run_in "$PLATFORM_ROOT" \ + "AI Workspace config contract" \ + "infra/scripts/check-ai-workspace-config-contract.sh" +run_in "$AI_ASSISTANT_DIR" \ + "AI Workspace Assistant server syntax" \ + "node --check src/server.mjs" +run_in "$AI_ASSISTANT_DIR" \ + "AI Workspace run profile smoke" \ + "npm run smoke:run-profile" +run_in "$PLATFORM_ROOT" \ + "Platform diff hygiene" \ + "git diff --check" + +section "Ops Gateway static and vertical gates" +if [ -d "$OPS_GATEWAY_REPO" ]; then + run_in "$OPS_GATEWAY_REPO" \ + "Ops Gateway TypeScript check" \ + "npm run check" + run_in "$OPS_GATEWAY_REPO" \ + "Ops Gateway build" \ + "npm run build" + if [ "$RUN_GATEWAY_SMOKE" = "1" ]; then + run_in "$OPS_GATEWAY_REPO" \ + "Ops Gateway smoke" \ + "DATABASE_URL='$GATEWAY_DATABASE_URL' NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN='$GATEWAY_INTERNAL_TOKEN' NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS='$GATEWAY_RUN_TOKEN_TTL_SECONDS' npm run smoke:gateway" + else + skip "Ops Gateway smoke (RUN_GATEWAY_SMOKE=$RUN_GATEWAY_SMOKE)" + fi + run_in "$OPS_GATEWAY_REPO" \ + "Ops Gateway diff hygiene" \ + "git diff --check" +else + fail "Ops Gateway repo not found: $OPS_GATEWAY_REPO" +fi + +section "Engine static gates" +if [ -d "$ENGINE_REPO" ]; then + run_in "$ENGINE_REPO" \ + "Engine AI Workspace client syntax" \ + "node --check nodedc-source/server/aiWorkspace/assistantRegistryClient.js" + run_in "$ENGINE_REPO" \ + "Engine diff hygiene" \ + "git diff --check" +else + fail "Engine repo not found: $ENGINE_REPO" +fi + +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/check-ai-workspace-topology.sh b/infra/scripts/check-ai-workspace-topology.sh new file mode 100755 index 0000000..6494c59 --- /dev/null +++ b/infra/scripts/check-ai-workspace-topology.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +INFRA_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +PLATFORM_ENV="${PLATFORM_ENV:-$INFRA_DIR/.env}" + +ASSISTANT_URL="${ASSISTANT_URL:-http://127.0.0.1:18082}" +HUB_URL="${HUB_URL:-http://127.0.0.1:18081}" +GATEWAY_URL="${GATEWAY_URL:-http://127.0.0.1:4100}" +TASK_URL="${TASK_URL:-http://task.local.nodedc/}" +ENGINE_URL="${ENGINE_URL:-http://127.0.0.1:5300/}" + +read_env() { + file="$1" + key="$2" + if [ ! -f "$file" ]; then + return 0 + fi + 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" +} + +http_status() { + url="$1" + curl -k -sS -o /dev/null -w "%{http_code}" --max-time 3 "$url" 2>/dev/null || true +} + +http_body() { + url="$1" + curl -k -sS --max-time 3 "$url" 2>/dev/null || true +} + +ok_status() { + code="$1" + case "$code" in + 2*|3*) printf true ;; + *) printf false ;; + esac +} + +json_number() { + key="$1" + sed -n "s/.*\"$key\"[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p" | head -n 1 +} + +url_kind() { + value="$1" + case "$value" in + "") + printf missing + ;; + *127.0.0.1*|*localhost*|*host.docker.internal*|*".local.nodedc"*|*ai-workspace-hub*|*agent-gateway*) + printf local + ;; + *100.[6-9][0-9].*|*100.1[01][0-9].*|*100.12[0-7].*|*.ts.net*) + printf tailscale + ;; + *172.22.0.222*|*".nas.nodedc"*) + printf synology-lan + ;; + *ai-hub.nodedc.ru*|*ops-agents.nodedc.ru*|*hub.nodedc.ru*|*ops.nodedc.ru*) + printf prod-public + ;; + http://172.*|http://192.168.*|http://10.*) + printf lan + ;; + *) + printf custom + ;; + esac +} + +bool_and() { + left="$1" + right="$2" + if [ "$left" = true ] && [ "$right" = true ]; then + printf true + else + printf false + fi +} + +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 ;; +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) + +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")") +worker_on_local_hub=false +if [ "$agents_online" -gt 0 ]; then + worker_on_local_hub=true +fi + +hub_runtime_kind=custom +case "$hub_public_kind:$hub_internal_kind" in + local:local|local:missing|missing:local) hub_runtime_kind=local ;; + tailscale:local|tailscale:tailscale|tailscale:missing) hub_runtime_kind=tailscale ;; + prod-public:prod-public|prod-public:missing|missing:prod-public) hub_runtime_kind=prod-public ;; + *) hub_runtime_kind=mixed ;; +esac + +classification=contract-only +if [ "$local_services_ok" = true ]; then + if [ "$engine_ok" = true ] \ + && [ "$worker_on_local_hub" = true ] \ + && [ "$ops_entitlement_kind" = local ] \ + && [ "$hub_runtime_kind" = local ]; then + classification=true-local-e2e + elif [ "$engine_ok" = true ] \ + && [ "$worker_on_local_hub" = true ] \ + && { [ "$ops_entitlement_kind" = tailscale ] || [ "$hub_runtime_kind" = tailscale ]; }; then + classification=tunnel-local-e2e + elif [ "$hub_runtime_kind" = prod-public ] || [ "$worker_on_local_hub" = false ]; then + classification=hybrid-prod-bridge + else + classification=local-ops-vertical + fi +elif [ "$gateway_ok" = true ] && [ "$task_ok" = true ]; then + classification=local-ops-vertical +elif [ "$task_ok" = true ] || [ "$engine_ok" = true ]; then + classification=local-ui-only +fi + +cat < +AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED=false +``` + ## Локальные домены для первичной проверки На Mac для первичной проверки добавить в `/etc/hosts`: @@ -137,6 +145,8 @@ GATEWAY_REPO=/Users/dcconstructions/Downloads/mnt/data/NODEDC_TASKMANAGER_CODEXA Скрипт не запускает Docker сам: на NAS `sudo` интерактивный, поэтому команды применения печатаются в конце. +По умолчанию sync source-копий на SMB/NAS не сохраняет owner/group/perms/times. Для Docker build важен контент, а macOS/Synology metadata может падать на `utimensat Operation timed out`. Если metadata действительно нужно сохранить для отдельного ручного случая, запускать с `RSYNC_PRESERVE_METADATA=1`. + Что синхронизируется: - Platform compose/Caddy. diff --git a/infra/synology/apply-current-runtime.sh b/infra/synology/apply-current-runtime.sh index 677a33e..69f00a8 100755 --- a/infra/synology/apply-current-runtime.sh +++ b/infra/synology/apply-current-runtime.sh @@ -57,6 +57,10 @@ echo "== ai workspace assistant hub target check ==" "${DOCKER_BIN}" exec nodedc-platform-ai-workspace-assistant-1 sh -lc \ 'test "$AI_WORKSPACE_HUB_PUBLIC_URL" = "wss://ai-hub.nodedc.ru/api/ai-workspace/hub" && test -z "$AI_WORKSPACE_HUB_FALLBACK_URLS" && echo ai-workspace-prod-hub-target-ok' +echo "== ai workspace assistant Ops entitlement adapter check ==" +"${DOCKER_BIN}" exec nodedc-platform-ai-workspace-assistant-1 sh -lc \ + 'test "$AI_WORKSPACE_OPS_ENTITLEMENT_URL" = "http://172.22.0.222:18190/api/internal/v1/ai-workspace/entitlements" && test -n "$AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN" && echo ai-workspace-ops-entitlement-env-ok' + echo "== clear authentik global brand css ==" DOCKER_BIN="${DOCKER_BIN}" bash "${PLATFORM_DIR}/clear-authentik-brand-css.sh" diff --git a/infra/synology/backup-current.sh b/infra/synology/backup-current.sh index 7829888..7bb274e 100755 --- a/infra/synology/backup-current.sh +++ b/infra/synology/backup-current.sh @@ -46,6 +46,7 @@ rsync_dir "${NAS_ROOT}/authentik/custom-templates/" "${BACKUP_DIR}/files/authent rsync_dir "${NAS_ROOT}/platform/authentik/" "${BACKUP_DIR}/files/platform/authentik/" rsync_dir "${NAS_ROOT}/platform/notification-core/" "${BACKUP_DIR}/files/platform/notification-core/" rsync_dir "${NAS_ROOT}/platform/ai-workspace-hub/" "${BACKUP_DIR}/files/platform/ai-workspace-hub/" +rsync_dir "${NAS_ROOT}/platform/ai-workspace-assistant/" "${BACKUP_DIR}/files/platform/ai-workspace-assistant/" rsync_file "${NAS_ROOT}/platform/.env.synology" "${BACKUP_DIR}/files/platform/" rsync_file "${NAS_ROOT}/platform/.env.synology.example" "${BACKUP_DIR}/files/platform/" @@ -73,6 +74,7 @@ Contains: - Platform runtime config: platform/.env.synology, compose, Caddyfile - Notification Core source/config: platform/notification-core, platform compose/env - AI Workspace Hub source/config: platform/ai-workspace-hub, platform compose/env +- AI Workspace Assistant source/config: platform/ai-workspace-assistant, platform compose/env - Tasker runtime config: tasker/plane-app/.env.synology, compose, Synology override - Ops Agents Gateway runtime config: ops-agents/.env, compose @@ -101,8 +103,24 @@ sudo "${DOCKER_BIN}" compose \\ --env-file "${ENV_FILE}" \\ -f "${COMPOSE_FILE}" \\ exec -T postgresql-authentik \\ - sh -lc 'pg_restore --list /dev/stdin >/dev/null' \\ - < "\${BACKUP_DIR}/authentik-postgres.dump" + rm -f /tmp/authentik-postgres.dump + +sudo "${DOCKER_BIN}" compose \\ + --env-file "${ENV_FILE}" \\ + -f "${COMPOSE_FILE}" \\ + cp "\${BACKUP_DIR}/authentik-postgres.dump" postgresql-authentik:/tmp/authentik-postgres.dump + +sudo "${DOCKER_BIN}" compose \\ + --env-file "${ENV_FILE}" \\ + -f "${COMPOSE_FILE}" \\ + exec -T postgresql-authentik \\ + pg_restore --list /tmp/authentik-postgres.dump >/dev/null + +sudo "${DOCKER_BIN}" compose \\ + --env-file "${ENV_FILE}" \\ + -f "${COMPOSE_FILE}" \\ + exec -T postgresql-authentik \\ + rm -f /tmp/authentik-postgres.dump if command -v sha256sum >/dev/null 2>&1; then (cd "\${BACKUP_DIR}" && sha256sum authentik-postgres.dump > SHA256SUMS) @@ -132,8 +150,24 @@ sudo "${DOCKER_BIN}" compose \\ --env-file "${ENV_FILE}" \\ -f "${COMPOSE_FILE}" \\ exec -T notification-postgres \\ - sh -lc 'pg_restore --list /dev/stdin >/dev/null' \\ - < "\${BACKUP_DIR}/notification-postgres.dump" + rm -f /tmp/notification-postgres.dump + +sudo "${DOCKER_BIN}" compose \\ + --env-file "${ENV_FILE}" \\ + -f "${COMPOSE_FILE}" \\ + cp "\${BACKUP_DIR}/notification-postgres.dump" notification-postgres:/tmp/notification-postgres.dump + +sudo "${DOCKER_BIN}" compose \\ + --env-file "${ENV_FILE}" \\ + -f "${COMPOSE_FILE}" \\ + exec -T notification-postgres \\ + pg_restore --list /tmp/notification-postgres.dump >/dev/null + +sudo "${DOCKER_BIN}" compose \\ + --env-file "${ENV_FILE}" \\ + -f "${COMPOSE_FILE}" \\ + exec -T notification-postgres \\ + rm -f /tmp/notification-postgres.dump echo "notification-db-dump-ok: \${BACKUP_DIR}/notification-postgres.dump" EOF @@ -158,8 +192,18 @@ sudo "${DOCKER_BIN}" "\${compose_args[@]}" \\ sudo "${DOCKER_BIN}" "\${compose_args[@]}" \\ exec -T plane-db \\ - sh -lc 'pg_restore --list /dev/stdin >/dev/null' \\ - < "\${BACKUP_DIR}/tasker-postgres.dump" + rm -f /tmp/tasker-postgres.dump + +sudo "${DOCKER_BIN}" "\${compose_args[@]}" \\ + cp "\${BACKUP_DIR}/tasker-postgres.dump" plane-db:/tmp/tasker-postgres.dump + +sudo "${DOCKER_BIN}" "\${compose_args[@]}" \\ + exec -T plane-db \\ + pg_restore --list /tmp/tasker-postgres.dump >/dev/null + +sudo "${DOCKER_BIN}" "\${compose_args[@]}" \\ + exec -T plane-db \\ + rm -f /tmp/tasker-postgres.dump echo "tasker-db-dump-ok: \${BACKUP_DIR}/tasker-postgres.dump" EOF @@ -183,8 +227,24 @@ sudo "${DOCKER_BIN}" compose \\ --env-file .env \\ -f docker-compose.synology.yml \\ exec -T postgres \\ - sh -lc 'pg_restore --list /dev/stdin >/dev/null' \\ - < "\${BACKUP_DIR}/ops-agents-postgres.dump" + rm -f /tmp/ops-agents-postgres.dump + +sudo "${DOCKER_BIN}" compose \\ + --env-file .env \\ + -f docker-compose.synology.yml \\ + cp "\${BACKUP_DIR}/ops-agents-postgres.dump" postgres:/tmp/ops-agents-postgres.dump + +sudo "${DOCKER_BIN}" compose \\ + --env-file .env \\ + -f docker-compose.synology.yml \\ + exec -T postgres \\ + pg_restore --list /tmp/ops-agents-postgres.dump >/dev/null + +sudo "${DOCKER_BIN}" compose \\ + --env-file .env \\ + -f docker-compose.synology.yml \\ + exec -T postgres \\ + rm -f /tmp/ops-agents-postgres.dump echo "ops-agents-db-dump-ok: \${BACKUP_DIR}/ops-agents-postgres.dump" EOF diff --git a/infra/synology/check-ai-workspace-apply-plan.sh b/infra/synology/check-ai-workspace-apply-plan.sh new file mode 100755 index 0000000..c2768bc --- /dev/null +++ b/infra/synology/check-ai-workspace-apply-plan.sh @@ -0,0 +1,190 @@ +#!/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}" +GATEWAY_REPO="${GATEWAY_REPO:-${PLATFORM_REPO}/../../data/NODEDC_TASKMANAGER_CODEXAPI}" +ENGINE_REPO="${ENGINE_REPO:-${PLATFORM_REPO}/../NODEDC_ENGINE_INFRA}" + +NAS_PLATFORM_ENV="${NAS_PLATFORM_ENV:-${NAS_ROOT}/platform/.env.synology}" +NAS_GATEWAY_ENV="${NAS_GATEWAY_ENV:-${NAS_ROOT}/ops-agents/.env}" + +passed=0 +failed=0 +warnings=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 +} + +warn() { + warnings=$((warnings + 1)) + printf 'WARN %s\n' "$1" >&2 +} + +read_env() { + local file="$1" + local key="$2" + [[ -f "${file}" ]] || return 0 + 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_path() { + local path="$1" + local label="$2" + if [[ -e "${path}" ]]; then + pass "${label}" + else + fail "${label} (missing: ${path})" + fi +} + +require_file_contains() { + local file="$1" + local needle="$2" + local label="$3" + if [[ ! -f "${file}" ]]; then + fail "${label} (missing file: ${file})" + return + fi + if grep -Fq "${needle}" "${file}"; then + pass "${label}" + else + fail "${label} (missing: ${needle})" + fi +} + +require_env_value() { + local file="$1" + local key="$2" + local expected="$3" + local label="$4" + local actual + actual="$(read_env "${file}" "${key}" || true)" + if [[ "${actual}" == "${expected}" ]]; then + pass "${label}" + else + fail "${label} (expected ${key}=${expected}, got ${actual:-})" + fi +} + +require_env_nonempty() { + local file="$1" + local key="$2" + local label="$3" + local actual + actual="$(read_env "${file}" "${key}" || true)" + if [[ -n "${actual}" && "${actual}" != replace-with-* && "${actual}" != change-me-* ]]; then + pass "${label}" + else + fail "${label} (${key} missing or placeholder)" + fi +} + +section "Local prerequisites" +require_path "${NAS_ROOT}" "NAS mount exists" +require_path "${GATEWAY_REPO}" "Ops Gateway repo exists" +require_path "${ENGINE_REPO}" "Engine repo exists" +require_path "${PLATFORM_REPO}/infra/scripts/check-ai-workspace-release-gates.sh" "Predeploy gate runner exists" +require_path "${PLATFORM_REPO}/infra/scripts/check-ai-workspace-config-contract.sh" "Config contract checker exists" +require_path "${PLATFORM_REPO}/infra/synology/backup-current.sh" "Synology backup script exists" +require_path "${PLATFORM_REPO}/infra/synology/apply-current-runtime.sh" "Synology apply script exists" +require_path "${PLATFORM_REPO}/infra/synology/verify-current-runtime.sh" "Synology verify script exists" + +section "Source and migration audit" +require_path "${PLATFORM_REPO}/services/ai-workspace-assistant/src/server.mjs" "AI Workspace Assistant source present" +require_path "${PLATFORM_REPO}/services/ai-workspace-assistant/src/templates/install-windows.ps1" "Worker installer template present" +require_path "${GATEWAY_REPO}/migrations/004_run_grants.sql" "Ops Gateway token-scoped run grants migration present" +require_file_contains "${GATEWAY_REPO}/docker-entrypoint.sh" "npm run migrate:dist" "Ops Gateway image applies migrations on startup" +require_file_contains "${PLATFORM_REPO}/infra/synology/backup-current.sh" "ai-workspace-assistant" "Backup includes AI Workspace Assistant source" +require_file_contains "${PLATFORM_REPO}/infra/synology/deploy-current.sh" "RSYNC_METADATA_ARGS" "Deploy sync avoids fragile NAS metadata by default" +require_file_contains "${PLATFORM_REPO}/infra/synology/apply-current-runtime.sh" "ai-workspace-ops-entitlement-env-ok" "Apply verifies Ops entitlement env" +require_file_contains "${PLATFORM_REPO}/infra/synology/verify-current-runtime.sh" "ai-workspace-ops-entitlement-env-ok" "Verify checks Ops entitlement env" + +section "NAS env readiness" +if [[ -f "${NAS_PLATFORM_ENV}" ]]; then + pass "NAS Platform env exists" + require_env_value "${NAS_PLATFORM_ENV}" "AI_WORKSPACE_HUB_PUBLIC_URL" "wss://ai-hub.nodedc.ru/api/ai-workspace/hub" "NAS Platform worker-facing Hub URL" + require_env_value "${NAS_PLATFORM_ENV}" "AI_WORKSPACE_HUB_INTERNAL_URL" "https://ai-hub.nodedc.ru" "NAS Platform internal Hub URL" + require_env_value "${NAS_PLATFORM_ENV}" "AI_WORKSPACE_HUB_FALLBACK_URLS" "" "NAS Platform Hub fallback URLs disabled" + require_env_value "${NAS_PLATFORM_ENV}" "AI_WORKSPACE_OPS_ENTITLEMENT_URL" "http://172.22.0.222:18190/api/internal/v1/ai-workspace/entitlements" "NAS Platform Ops entitlement URL" + require_env_nonempty "${NAS_PLATFORM_ENV}" "AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN" "NAS Platform Ops entitlement token configured" + require_env_nonempty "${NAS_PLATFORM_ENV}" "AI_WORKSPACE_ASSISTANT_TOKEN" "NAS Platform Assistant token configured" + require_env_nonempty "${NAS_PLATFORM_ENV}" "AI_WORKSPACE_HUB_TOKEN" "NAS Platform Hub token configured" +else + fail "NAS Platform env missing: ${NAS_PLATFORM_ENV}" +fi + +if [[ -f "${NAS_GATEWAY_ENV}" ]]; then + pass "NAS Ops Gateway env exists" + require_env_value "${NAS_GATEWAY_ENV}" "NODEDC_AGENT_GATEWAY_PUBLIC_URL" "https://ops-agents.nodedc.ru" "NAS Gateway worker-facing MCP URL" + require_env_value "${NAS_GATEWAY_ENV}" "NODEDC_TASKER_INTERNAL_URL" "http://172.22.0.222:18090" "NAS Gateway downstream Tasker URL" + require_env_value "${NAS_GATEWAY_ENV}" "NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS" "43200" "NAS Gateway AI Workspace run token TTL" + require_env_nonempty "${NAS_GATEWAY_ENV}" "NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN" "NAS Gateway internal token configured" + require_env_nonempty "${NAS_GATEWAY_ENV}" "NODEDC_INTERNAL_ACCESS_TOKEN" "NAS Gateway Tasker internal token configured" + require_env_nonempty "${NAS_GATEWAY_ENV}" "POSTGRES_PASSWORD" "NAS Gateway Postgres password configured" +else + fail "NAS Ops Gateway env missing: ${NAS_GATEWAY_ENV}" +fi + +section "Controlled apply order" +cat </run-authentik-db-dump-on-synology.sh + bash /volume1/docker/nodedc-platform/backups//run-notification-db-dump-on-synology.sh + bash /volume1/docker/nodedc-platform/backups//run-ops-agents-db-dump-on-synology.sh + # Tasker DB dump only if Tasker backend/schema changes are included: + bash /volume1/docker/nodedc-platform/backups//run-tasker-db-dump-on-synology.sh + +4. Sync source to NAS mount: + cd ${PLATFORM_REPO} + NAS_ROOT=${NAS_ROOT} GATEWAY_REPO=${GATEWAY_REPO} infra/synology/deploy-current.sh + +5. Apply Ops Gateway first on Synology: + cd /volume1/docker/nodedc-platform/ops-agents + sudo /usr/local/bin/docker compose --env-file .env -f docker-compose.synology.yml up -d --build --force-recreate + curl -fsS http://172.22.0.222:18190/readyz + +6. Apply Platform AI Workspace services on Synology: + cd /volume1/docker/nodedc-platform/platform + sudo bash apply-current-runtime.sh + +7. Verify runtime: + cd /volume1/docker/nodedc-platform/platform + sudo bash verify-current-runtime.sh +EOF + +section "Summary" +printf 'passed=%s failed=%s warnings=%s\n' "${passed}" "${failed}" "${warnings}" + +if [[ "${failed}" -ne 0 ]]; then + exit 1 +fi diff --git a/infra/synology/deploy-current.sh b/infra/synology/deploy-current.sh index a422902..2b1ca97 100755 --- a/infra/synology/deploy-current.sh +++ b/infra/synology/deploy-current.sh @@ -11,6 +11,11 @@ TASKER_CHANGED_BASE="${TASKER_CHANGED_BASE:-}" GATEWAY_REPO="${GATEWAY_REPO:-}" SYNC_AUTHENTIK_TEMPLATES="${SYNC_AUTHENTIK_TEMPLATES:-0}" +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 @@ -19,15 +24,15 @@ fi mkdir -p "${NAS_ROOT}/platform" -rsync -av \ +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 -av "${RSYNC_METADATA_ARGS[@]}" \ "${PLATFORM_REPO}/infra/synology/Caddyfile.http" \ "${NAS_ROOT}/platform/Caddyfile.http" -rsync -av \ +rsync -av "${RSYNC_METADATA_ARGS[@]}" \ "${PLATFORM_REPO}/infra/synology/deploy-current.sh" \ "${PLATFORM_REPO}/infra/synology/backup-current.sh" \ "${PLATFORM_REPO}/infra/synology/apply-current-runtime.sh" \ @@ -36,7 +41,7 @@ rsync -av \ "${NAS_ROOT}/platform/" mkdir -p "${NAS_ROOT}/platform/notification-core" -rsync -av --delete \ +rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \ --exclude='node_modules/' \ --exclude='.env' \ --exclude='.env.*' \ @@ -44,7 +49,7 @@ rsync -av --delete \ "${NAS_ROOT}/platform/notification-core/" mkdir -p "${NAS_ROOT}/platform/ai-workspace-hub" -rsync -av --delete \ +rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \ --exclude='node_modules/' \ --exclude='.env' \ --exclude='.env.*' \ @@ -52,7 +57,7 @@ rsync -av --delete \ "${NAS_ROOT}/platform/ai-workspace-hub/" mkdir -p "${NAS_ROOT}/platform/ai-workspace-assistant" -rsync -av --delete \ +rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \ --exclude='node_modules/' \ --exclude='.env' \ --exclude='.env.*' \ @@ -61,7 +66,7 @@ rsync -av --delete \ if [[ "${SYNC_AUTHENTIK_TEMPLATES}" == "1" ]]; then mkdir -p "${NAS_ROOT}/authentik/custom-templates" - rsync -av --delete \ + rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \ "${PLATFORM_REPO}/infra/authentik/custom-templates/" \ "${NAS_ROOT}/authentik/custom-templates/" else @@ -75,7 +80,7 @@ if [[ -n "${LAUNCHER_REPO}" ]]; then fi mkdir -p "${NAS_ROOT}/launcher/source" - rsync -av --delete \ + rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \ --exclude='.git/' \ --exclude='node_modules/' \ --exclude='dist/' \ @@ -95,7 +100,7 @@ if [[ -n "${TASKER_REPO}" ]]; then mkdir -p "${NAS_ROOT}/tasker/plane-src" "${NAS_ROOT}/tasker/plane-app" - rsync -av \ + rsync -av "${RSYNC_METADATA_ARGS[@]}" \ "${TASKER_REPO}/plane-app/docker-compose.yaml" \ "${NAS_ROOT}/tasker/plane-app/docker-compose.yaml" @@ -121,14 +126,14 @@ if [[ -n "${TASKER_REPO}" ]]; then if [[ -f "${source_path}" ]]; then mkdir -p "$(dirname -- "${target_path}")" - rsync -av "${source_path}" "${target_path}" + rsync -av "${RSYNC_METADATA_ARGS[@]}" "${source_path}" "${target_path}" else echo "skip deleted Tasker file in changed sync: ${changed_file}" fi done elif [[ "${TASKER_SYNC_SOURCE}" == "1" ]]; then echo "TASKER_SYNC_SOURCE=1: syncing full Tasker source without delete" - rsync -av \ + rsync -av "${RSYNC_METADATA_ARGS[@]}" \ --exclude='.git/' \ --exclude='node_modules/' \ --exclude='.pnpm-store/' \ @@ -153,7 +158,7 @@ if [[ -n "${TASKER_REPO}" ]]; then fi if [[ -f "${TASKER_REPO}/plane-app/docker-compose.synology.override.yml" ]]; then - rsync -av \ + rsync -av "${RSYNC_METADATA_ARGS[@]}" \ "${TASKER_REPO}/plane-app/docker-compose.synology.override.yml" \ "${NAS_ROOT}/tasker/plane-app/docker-compose.synology.override.yml" else @@ -170,7 +175,7 @@ if [[ -n "${GATEWAY_REPO}" ]]; then fi mkdir -p "${NAS_ROOT}/ops-agents" - rsync -av --delete \ + rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \ --exclude='.git/' \ --exclude='node_modules/' \ --exclude='dist/' \ diff --git a/infra/synology/prepare-ai-workspace-env.sh b/infra/synology/prepare-ai-workspace-env.sh new file mode 100755 index 0000000..9f44b16 --- /dev/null +++ b/infra/synology/prepare-ai-workspace-env.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail + +NAS_ROOT="${NAS_ROOT:-/Volumes/docker/nodedc-platform}" +PLATFORM_ENV="${PLATFORM_ENV:-${NAS_ROOT}/platform/.env.synology}" +GATEWAY_ENV="${GATEWAY_ENV:-${NAS_ROOT}/ops-agents/.env}" +BACKUP_ROOT="${BACKUP_ROOT:-${NAS_ROOT}/backups/env-prep}" +TIMESTAMP="${TIMESTAMP:-$(date +%Y%m%d-%H%M%S)}" +BACKUP_DIR="${BACKUP_DIR:-${BACKUP_ROOT}/${TIMESTAMP}}" + +mkdir -p "${BACKUP_DIR}" + +read_env() { + local file="$1" + local key="$2" + [[ -f "${file}" ]] || return 0 + 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}" +} + +backup_file() { + local file="$1" + local label="$2" + if [[ ! -f "${file}" ]]; then + echo "missing ${label}: ${file}" >&2 + exit 1 + fi + local backup_path="${BACKUP_DIR}/$(basename "${file}").${label}.bak" + if ! COPYFILE_DISABLE=1 cp -X "${file}" "${backup_path}" 2>/dev/null; then + cat "${file}" > "${backup_path}" + fi + chmod 600 "${backup_path}" +} + +set_env_value() { + local file="$1" + local key="$2" + local value="$3" + local tmp + tmp="$(mktemp)" + if grep -qE "^${key}=" "${file}"; then + awk -v key="${key}" -v value="${value}" ' + BEGIN { replaced = 0 } + $0 ~ "^[[:space:]]*#" { print; next } + index($0, key "=") == 1 { + print key "=" value + replaced = 1 + next + } + { print } + END { + if (!replaced) { + print key "=" value + } + } + ' "${file}" > "${tmp}" + else + cat "${file}" > "${tmp}" + printf '\n%s=%s\n' "${key}" "${value}" >> "${tmp}" + fi + cat "${tmp}" > "${file}" + rm -f "${tmp}" + chmod 600 "${file}" +} + +gateway_internal_token="$(read_env "${GATEWAY_ENV}" NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN || true)" +if [[ -z "${gateway_internal_token}" || "${gateway_internal_token}" == replace-with-* || "${gateway_internal_token}" == change-me-* ]]; then + echo "Gateway NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN is missing or placeholder in ${GATEWAY_ENV}" >&2 + exit 1 +fi + +backup_file "${PLATFORM_ENV}" platform +backup_file "${GATEWAY_ENV}" ops-agents + +set_env_value "${PLATFORM_ENV}" AI_WORKSPACE_HUB_PUBLIC_URL "wss://ai-hub.nodedc.ru/api/ai-workspace/hub" +set_env_value "${PLATFORM_ENV}" AI_WORKSPACE_HUB_INTERNAL_URL "https://ai-hub.nodedc.ru" +set_env_value "${PLATFORM_ENV}" AI_WORKSPACE_HUB_FALLBACK_URLS "" +set_env_value "${PLATFORM_ENV}" AI_WORKSPACE_OPS_ENTITLEMENT_URL "http://172.22.0.222:18190/api/internal/v1/ai-workspace/entitlements" +set_env_value "${PLATFORM_ENV}" AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN "${gateway_internal_token}" +set_env_value "${PLATFORM_ENV}" AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED "false" + +set_env_value "${GATEWAY_ENV}" NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS "43200" + +cat < server.serverName), + requiredMcpServerNames: mcpServers.filter((server) => server.required).map((server) => server.serverName), + }, + diagnostics: { + schemaVersion: "ai-workspace.run-profile.diagnostics.v1", + dynamicProfile: true, + sourceSurface: "engine", + modeId: "ops", + entitlementAdapters: { + source: "adapters+settings", + adapters: [{ appId: "ops", status: "ok", required: true }], + }, + mcpServerNames: mcpServers.map((server) => server.serverName), + }, +}; +runProfile.diagnostics.profileHash = runProfileHash(runProfile); +const publicProfile = redactRunProfile(runProfile); + +assert.deepEqual(Object.keys(appGrants), ["ops"]); +assert.equal(appGrants.ops.source, "entitlement-adapter"); +assert.equal(appGrants.ops.appId, "ops"); +assert.deepEqual(appGrantSummary.ops.scopes, ["workspace:read", "project:read", "issue:read"]); +assert.equal(appGrantSummary.ops.hasMcpServers, true); +assert.deepEqual(appGrantSummary.ops.mcpServerNames, ["nodedc_ops_agent"]); + +assert.equal(mcpServers.length, 1); +assert.equal(mcpServers[0].appId, "ops"); +assert.equal(mcpServers[0].serverName, "nodedc_ops_agent"); +assert.equal(mcpServers[0].required, true); +assert.equal(mcpServers[0].httpHeaders.Authorization, `Bearer ${SECRET_TOKEN}`); +assert.equal(mcpServers[0].httpHeaders.Accept, "application/json"); + +assert.equal(publicProfile.toolProfile.mcpServers.length, 1); +assert.equal(publicProfile.toolProfile.mcpServers[0].serverName, "nodedc_ops_agent"); +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(JSON.stringify(publicProfile).includes(SECRET_TOKEN), false); +assert.match(runProfile.diagnostics.profileHash, /^[a-f0-9]{16}$/); + +console.log(JSON.stringify({ + ok: true, + checks: [ + "adapter_grant_normalized", + "token_scoped_ops_mcp_in_run_profile", + "public_run_profile_redacts_mcp_headers", + "stable_public_profile_hash", + ], + mcpServerNames: runProfile.toolProfile.mcpServerNames, + profileHash: runProfile.diagnostics.profileHash, +}, null, 2)); + +function normalizeEntitlementAdapterAppGrants(payload, currentAdapter) { + const source = isPlainObject(payload?.appGrants) + ? payload.appGrants + : Array.isArray(payload?.appGrants) + ? payload.appGrants + : payload?.grant || payload?.appGrant || payload?.entitlement || payload?.entitlements || payload?.grants || payload; + const out = {}; + if (Array.isArray(source)) { + for (const item of source) { + const grant = normalizeEntitlementAdapterGrant(item, currentAdapter); + if (grant?.appId) out[grant.appId] = grant; + } + return out; + } + if (!isPlainObject(source)) return out; + if (source.mcpServers || source.scopes || source.appId || source.app_id || source.surface) { + const grant = normalizeEntitlementAdapterGrant(source, currentAdapter); + if (grant?.appId) out[grant.appId] = grant; + return out; + } + for (const [key, value] of Object.entries(source)) { + const grant = normalizeEntitlementAdapterGrant(value, { ...currentAdapter, appId: normalizeKey(key) || currentAdapter.appId }); + if (grant?.appId) out[grant.appId] = grant; + } + return out; +} + +function normalizeEntitlementAdapterGrant(value, currentAdapter) { + if (!isPlainObject(value)) return null; + const appId = normalizeKey(value.appId || value.app_id || currentAdapter.appId); + if (!appId) return null; + return { + ...value, + appId, + appTitle: optionalString(value.appTitle || value.app_title || value.title || currentAdapter.title), + surface: optionalString(value.surface) || appId, + source: "entitlement-adapter", + adapterId: currentAdapter.id, + }; +} + +function runProfileMcpServersFromAppGrants(settings, appGrantsInput = {}) { + const servers = []; + const metadata = isPlainObject(settings?.metadata) ? settings.metadata : {}; + collectInstallerMcpServers(servers, metadata.mcpServers, {}); + const grants = isPlainObject(appGrantsInput) ? appGrantsInput : {}; + for (const [appId, grant] of Object.entries(grants)) { + if (!isPlainObject(grant)) continue; + collectInstallerMcpServers(servers, grant.mcpServers, { + appId: optionalString(grant.appId) || normalizeKey(appId), + appTitle: optionalString(grant.appTitle || grant.title), + }); + } + + const byServerName = new Map(); + for (const server of servers.map(sanitizeInstallerMcpServer).filter(Boolean)) { + if (server.enabled === false) continue; + byServerName.set(server.serverName, server); + } + return Array.from(byServerName.values()); +} + +function summarizeRunAppGrants(metadata) { + const grants = isPlainObject(metadata?.appGrants) ? metadata.appGrants : {}; + const out = {}; + for (const [key, value] of Object.entries(grants)) { + if (!isPlainObject(value)) continue; + const appId = optionalString(value.appId) || normalizeKey(key); + if (!appId) continue; + const mcpServers = Array.isArray(value.mcpServers) + ? value.mcpServers + : isPlainObject(value.mcpServers) + ? Object.values(value.mcpServers) + : []; + out[appId] = { + appId, + appTitle: optionalString(value.appTitle || value.title), + surface: optionalString(value.surface) || appId, + updatedAt: optionalString(value.updatedAt || value.updated_at), + context: redactForPublicDiagnostics(isPlainObject(value.context) ? value.context : {}), + scopes: uniqueStrings(Array.isArray(value.scopes) ? value.scopes : []), + hasMcpServers: mcpServers.length > 0, + mcpServerNames: mcpServers + .map((server) => safeMcpServerName(server?.serverName || server?.server_name || server?.name)) + .filter(Boolean), + }; + } + return out; +} + +function collectInstallerMcpServers(target, value, defaults = {}) { + const items = Array.isArray(value) + ? value + : isPlainObject(value) + ? Object.values(value) + : []; + for (const item of items) { + if (!isPlainObject(item)) continue; + target.push({ ...defaults, ...item }); + } +} + +function sanitizeInstallerMcpServer(raw) { + if (!isPlainObject(raw)) return null; + const serverName = safeMcpServerName(raw.serverName || raw.server_name || raw.name); + const url = optionalString(raw.url); + if (!serverName || !url) return null; + const httpHeaders = sanitizeInstallerMcpHeaders(raw.httpHeaders || raw.http_headers || raw.headers); + return { + appId: normalizeKey(raw.appId || raw.app_id) || "global", + appTitle: optionalString(raw.appTitle || raw.app_title || raw.title), + serverName, + url, + enabled: raw.enabled !== false, + required: raw.required === true, + startupTimeoutSec: sanitizeInteger(raw.startupTimeoutSec || raw.startup_timeout_sec, 20, 1, 600), + toolTimeoutSec: sanitizeInteger(raw.toolTimeoutSec || raw.tool_timeout_sec, 60, 1, 3600), + httpHeaders, + }; +} + +function sanitizeInstallerMcpHeaders(value) { + if (!isPlainObject(value)) return {}; + const headers = {}; + for (const [key, rawValue] of Object.entries(value)) { + const headerName = optionalString(key); + const headerValue = optionalString(rawValue); + if (!headerName || !headerValue || headerName.length > 120 || headerValue.length > 4000) continue; + headers[headerName] = headerValue; + } + return headers; +} + +function runProfileHash(runProfile) { + const publicProfile = redactRunProfile(runProfile); + const stableProfile = { + ...publicProfile, + runId: undefined, + createdAt: undefined, + diagnostics: { + ...(isPlainObject(publicProfile?.diagnostics) ? publicProfile.diagnostics : {}), + profileHash: undefined, + }, + }; + return createHash("sha256").update(JSON.stringify(stableProfile)).digest("hex").slice(0, 16); +} + +function redactRunProfile(runProfile) { + if (!isPlainObject(runProfile)) return null; + 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) + : [], + }, + }; +} + +function redactMcpServer(server) { + if (!isPlainObject(server)) return {}; + const headers = isPlainObject(server.httpHeaders) ? server.httpHeaders : {}; + return { + ...server, + httpHeaders: Object.fromEntries(Object.keys(headers).map((key) => [key, ""])), + headers: undefined, + }; +} + +function redactForPublicDiagnostics(value, depth = 0) { + if (depth > 6) return "[max-depth]"; + if (Array.isArray(value)) return value.map((item) => redactForPublicDiagnostics(item, depth + 1)); + if (!isPlainObject(value)) return value; + const out = {}; + for (const [key, item] of Object.entries(value)) { + const normalizedKey = normalizeKey(key); + if ( + normalizedKey.includes("token") + || normalizedKey.includes("secret") + || normalizedKey.includes("password") + || normalizedKey === "authorization" + || normalizedKey === "cookie" + || normalizedKey === "setcookie" + ) { + out[key] = ""; + } else { + out[key] = redactForPublicDiagnostics(item, depth + 1); + } + } + return out; +} + +function safeMcpServerName(value) { + const text = optionalString(value); + if (!text) return ""; + return text.replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 80); +} + +function sanitizeInteger(value, fallback, min, max) { + const number = Number(value || fallback); + if (!Number.isFinite(number)) return fallback; + return Math.min(Math.max(Math.trunc(number), min), max); +} + +function uniqueStrings(values) { + return Array.from(new Set((Array.isArray(values) ? values : []).map(optionalString).filter(Boolean))); +} + +function normalizeKey(value) { + return optionalString(value).toLowerCase().replace(/[^a-z0-9_-]+/g, "_").replace(/^_+|_+$/g, ""); +} + +function optionalString(value) { + if (value === null || value === undefined) return ""; + const text = String(value).trim(); + return text.length ? text : ""; +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/services/ai-workspace-assistant/src/server.mjs b/services/ai-workspace-assistant/src/server.mjs index 65c13ff..81c64f2 100644 --- a/services/ai-workspace-assistant/src/server.mjs +++ b/services/ai-workspace-assistant/src/server.mjs @@ -326,6 +326,8 @@ app.post("/api/ai-workspace/assistant/v1/threads/:threadId/dispatch", requireInt accessMode: "ops-write", }; } + const runProfile = await buildRunProfile({ owner, thread, executor, ownerSettings, bridgePayload }); + bridgePayload.runProfile = runProfile; let bridge = null; try { bridge = await dispatchExecutorMessage(executor, bridgePayload); @@ -379,6 +381,7 @@ app.post("/api/ai-workspace/assistant/v1/threads/:threadId/dispatch", requireInt executor, message, assistantMessage: null, + runProfile: redactRunProfile(runProfile), bridge, }); })); @@ -1018,6 +1021,7 @@ async function createBridgeRun({ owner, thread, executor, bridge, payload }) { const requestId = optionalString(bridge?.requestId); if (!requestId || bridge?.accepted !== true || bridge?.mode !== "hub") return null; const context = isPlainObject(payload?.context) ? payload.context : {}; + const runProfile = isPlainObject(payload?.runProfile) ? payload.runProfile : null; const metadata = { modeId: optionalString(context.modeId), modeTitle: optionalString(context.modeTitle), @@ -1025,6 +1029,7 @@ async function createBridgeRun({ owner, thread, executor, bridge, payload }) { threadTitle: optionalString(payload?.threadTitle) || thread.title, executorName: executor.name, executorType: executor.type, + runProfile: runProfile ? redactRunProfile(runProfile) : null, }; const result = await pool.query( `insert into ai_workspace_runs ( @@ -1426,6 +1431,367 @@ function buildBridgeMessagePayload({ thread, message, history, executor, command }; } +async function buildRunProfile({ owner, thread, executor, ownerSettings, bridgePayload }) { + const context = isPlainObject(bridgePayload?.context) ? bridgePayload.context : {}; + const sourceSurface = optionalString(context.sourceSurface) + || optionalString(context.surface) + || thread.originSurface + || "global"; + const targetContexts = isPlainObject(context.contexts) ? context.contexts : {}; + const enabledToolPacks = mergeToolPacks( + ownerSettings?.enabledToolPacks, + thread.enabledToolPacks, + bridgePayload?.enabledToolPacks + ); + const grantResolution = await resolveRunAppGrants({ owner, context, ownerSettings }); + const appGrants = summarizeRunAppGrants({ appGrants: grantResolution.appGrants }); + const mcpServers = runProfileMcpServersFromAppGrants(ownerSettings, grantResolution.appGrants); + const mcpServerNames = mcpServers.map((server) => server.serverName).filter(Boolean); + const requiredMcpServerNames = mcpServers + .filter((server) => server.required === true) + .map((server) => server.serverName) + .filter(Boolean); + const diagnostics = { + schemaVersion: "ai-workspace.run-profile.diagnostics.v1", + dynamicProfile: true, + contextReady: context.contextReady !== false, + accessMode: optionalString(context.accessMode) || "chat", + sourceSurface, + modeId: optionalString(context.modeId) || "ops", + targetSurfaces: Object.keys(targetContexts).map(normalizeKey).filter(Boolean).sort(), + enabledToolPacks, + appGrantIds: Object.keys(appGrants).sort(), + entitlementAdapters: grantResolution.diagnostics, + mcpServerNames, + requiredMcpServerNames, + missingContext: Array.isArray(context.missingContext) + ? context.missingContext.map(optionalString).filter(Boolean) + : [], + }; + const runProfile = { + schemaVersion: "ai-workspace.run-profile.v1", + runId: randomUUID(), + createdAt: new Date().toISOString(), + owner: publicOwner(owner), + executor: { + id: executor.id, + type: executor.type, + connectionMode: executor.connectionMode, + }, + sourceSurface, + modeId: optionalString(context.modeId) || "ops", + modeTitle: optionalString(context.modeTitle) || "", + targetContexts: redactForPublicDiagnostics(targetContexts), + enabledToolPacks, + appGrants, + toolProfile: { + schemaVersion: "ai-workspace.tool-profile.v1", + enabledToolPacks, + mcpServers, + mcpServerNames, + requiredMcpServerNames, + }, + policyPrompt: buildRunProfilePolicyPrompt({ context, diagnostics }), + diagnostics, + }; + runProfile.diagnostics.profileHash = runProfileHash(runProfile); + return runProfile; +} + +async function resolveRunAppGrants({ owner, context, ownerSettings }) { + const metadata = isPlainObject(ownerSettings?.metadata) ? ownerSettings.metadata : {}; + const staticAppGrants = isPlainObject(metadata.appGrants) ? metadata.appGrants : {}; + const appGrants = { ...staticAppGrants }; + const adapterDiagnostics = []; + const adapters = Array.isArray(config.entitlementAdapters) ? config.entitlementAdapters : []; + if (!adapters.length) { + return { + appGrants, + diagnostics: { + source: "settings", + adapters: [], + }, + }; + } + + for (const adapter of adapters) { + const result = await fetchRunEntitlementAdapter({ adapter, owner, context, ownerSettings }).catch((error) => ({ + ok: false, + error: errorMessage(error), + })); + if (!result.ok) { + adapterDiagnostics.push({ + appId: adapter.appId, + status: "error", + required: adapter.required === true, + error: sanitizeBridgeErrorText(result.error || "entitlement_adapter_failed"), + }); + if (adapter.required === true) { + const error = new Error(`entitlement_adapter_failed:${adapter.appId}`); + error.status = 502; + throw error; + } + continue; + } + const adapterAppGrants = normalizeEntitlementAdapterAppGrants(result.payload, adapter); + for (const [appId, grant] of Object.entries(adapterAppGrants)) { + if (!isPlainObject(grant)) continue; + const existing = isPlainObject(appGrants[appId]) ? appGrants[appId] : {}; + appGrants[appId] = { + ...existing, + ...grant, + appId, + mcpServers: Object.hasOwn(grant, "mcpServers") ? grant.mcpServers : existing.mcpServers, + }; + } + adapterDiagnostics.push({ + appId: adapter.appId, + status: "ok", + required: adapter.required === true, + grantIds: Object.keys(adapterAppGrants).sort(), + mcpServerNames: Object.values(adapterAppGrants) + .flatMap((grant) => { + const servers = Array.isArray(grant?.mcpServers) + ? grant.mcpServers + : isPlainObject(grant?.mcpServers) + ? Object.values(grant.mcpServers) + : []; + return servers.map((server) => safeMcpServerName(server?.serverName || server?.server_name || server?.name)); + }) + .filter(Boolean), + }); + } + + return { + appGrants, + diagnostics: { + source: adapterDiagnostics.some((item) => item.status === "ok") ? "adapters+settings" : "settings", + adapters: adapterDiagnostics, + }, + }; +} + +async function fetchRunEntitlementAdapter({ adapter, owner, context, ownerSettings }) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), adapter.timeoutMs); + const body = JSON.stringify({ + schemaVersion: "ai-workspace.entitlement-request.v1", + appId: adapter.appId, + owner: publicOwner(owner), + activeContext: redactForPublicDiagnostics(isPlainObject(ownerSettings?.activeContext) ? ownerSettings.activeContext : {}), + runContext: redactForPublicDiagnostics(context), + requestedAt: new Date().toISOString(), + }); + try { + const response = await fetch(adapter.url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + ...(adapter.authorization ? { Authorization: adapter.authorization } : {}), + ...adapter.headers, + }, + body, + signal: controller.signal, + }); + const text = await response.text(); + let payload = {}; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + payload = { ok: false, error: "invalid_json_response" }; + } + if (!response.ok || payload?.ok === false) { + throw new Error(optionalString(payload?.error || payload?.message) || `http_${response.status}`); + } + return { ok: true, payload }; + } catch (error) { + if (error?.name === "AbortError") throw new Error("entitlement_adapter_timeout"); + throw error; + } finally { + clearTimeout(timeout); + } +} + +function normalizeEntitlementAdapterAppGrants(payload, adapter) { + const source = isPlainObject(payload?.appGrants) + ? payload.appGrants + : Array.isArray(payload?.appGrants) + ? payload.appGrants + : payload?.grant || payload?.appGrant || payload?.entitlement || payload?.entitlements || payload?.grants || payload; + const out = {}; + if (Array.isArray(source)) { + for (const item of source) { + const grant = normalizeEntitlementAdapterGrant(item, adapter); + if (grant?.appId) out[grant.appId] = grant; + } + return out; + } + if (!isPlainObject(source)) return out; + if (source.mcpServers || source.scopes || source.appId || source.app_id || source.surface) { + const grant = normalizeEntitlementAdapterGrant(source, adapter); + if (grant?.appId) out[grant.appId] = grant; + return out; + } + for (const [key, value] of Object.entries(source)) { + const grant = normalizeEntitlementAdapterGrant(value, { ...adapter, appId: normalizeKey(key) || adapter.appId }); + if (grant?.appId) out[grant.appId] = grant; + } + return out; +} + +function normalizeEntitlementAdapterGrant(value, adapter) { + if (!isPlainObject(value)) return null; + const appId = normalizeKey(value.appId || value.app_id || adapter.appId); + if (!appId) return null; + return { + ...value, + appId, + appTitle: optionalString(value.appTitle || value.app_title || value.title || adapter.title), + surface: optionalString(value.surface) || appId, + source: "entitlement-adapter", + adapterId: adapter.id, + }; +} + +function runProfileMcpServersFromSettings(settings) { + const metadata = isPlainObject(settings?.metadata) ? settings.metadata : {}; + const appGrants = isPlainObject(metadata.appGrants) ? metadata.appGrants : {}; + return runProfileMcpServersFromAppGrants(settings, appGrants); +} + +function runProfileMcpServersFromAppGrants(settings, appGrantsInput = {}) { + const servers = []; + const metadata = isPlainObject(settings?.metadata) ? settings.metadata : {}; + collectInstallerMcpServers(servers, metadata.mcpServers, {}); + const appGrants = isPlainObject(appGrantsInput) ? appGrantsInput : {}; + for (const [appId, grant] of Object.entries(appGrants)) { + if (!isPlainObject(grant)) continue; + collectInstallerMcpServers(servers, grant.mcpServers, { + appId: optionalString(grant.appId) || normalizeKey(appId), + appTitle: optionalString(grant.appTitle || grant.title), + }); + } + + const byServerName = new Map(); + for (const server of servers.map(sanitizeInstallerMcpServer).filter(Boolean)) { + if (server.enabled === false) continue; + byServerName.set(server.serverName, server); + } + return Array.from(byServerName.values()); +} + +function summarizeRunAppGrants(metadata) { + const appGrants = isPlainObject(metadata?.appGrants) ? metadata.appGrants : {}; + const out = {}; + for (const [key, value] of Object.entries(appGrants)) { + if (!isPlainObject(value)) continue; + const appId = optionalString(value.appId) || normalizeKey(key); + if (!appId) continue; + const mcpServers = Array.isArray(value.mcpServers) + ? value.mcpServers + : isPlainObject(value.mcpServers) + ? Object.values(value.mcpServers) + : []; + out[appId] = { + appId, + appTitle: optionalString(value.appTitle || value.title), + surface: optionalString(value.surface) || appId, + updatedAt: optionalString(value.updatedAt || value.updated_at), + context: redactForPublicDiagnostics(isPlainObject(value.context) ? value.context : {}), + scopes: uniqueStrings(Array.isArray(value.scopes) ? value.scopes : []), + hasMcpServers: mcpServers.length > 0, + mcpServerNames: mcpServers + .map((server) => safeMcpServerName(server?.serverName || server?.server_name || server?.name)) + .filter(Boolean), + }; + } + return out; +} + +function buildRunProfilePolicyPrompt({ context, diagnostics }) { + const lines = [ + "AI Workspace dynamic run profile:", + `- source surface: ${diagnostics.sourceSurface}`, + `- mode: ${diagnostics.modeId}`, + `- access mode: ${diagnostics.accessMode}`, + `- context ready: ${diagnostics.contextReady ? "yes" : "no"}`, + `- 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"}`, + "- MCP tokens and headers are runtime secrets and must never be printed in public answers.", + ]; + const opsContext = isPlainObject(context?.contexts?.ops) ? context.contexts.ops : {}; + if (opsContext.opsWorkspaceSlug || opsContext.opsProjectId) { + lines.push( + `- Ops target workspace: ${opsContext.opsWorkspaceSlug || opsContext.opsWorkspaceId || "unknown"}`, + `- Ops target project: ${opsContext.opsProjectId || "unknown"}` + ); + } + const engineContext = isPlainObject(context?.contexts?.engine) ? context.contexts.engine : {}; + if (engineContext.workflowId || engineContext.agentNodeId) { + lines.push( + `- Engine target workflow: ${engineContext.workflowId || "unknown"}`, + `- Engine target agent node: ${engineContext.agentNodeId || "unknown"}` + ); + } + return lines.join("\n"); +} + +function runProfileHash(runProfile) { + const publicProfile = redactRunProfile(runProfile); + const stableProfile = { + ...publicProfile, + runId: undefined, + createdAt: undefined, + diagnostics: { + ...(isPlainObject(publicProfile?.diagnostics) ? publicProfile.diagnostics : {}), + profileHash: undefined, + }, + }; + return createHash("sha256").update(JSON.stringify(stableProfile)).digest("hex").slice(0, 16); +} + +function redactRunProfile(runProfile) { + if (!isPlainObject(runProfile)) return null; + 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) + : [], + }, + }; +} + +function redactMcpServer(server) { + if (!isPlainObject(server)) return {}; + const headers = isPlainObject(server.httpHeaders) ? server.httpHeaders : {}; + return { + ...server, + httpHeaders: Object.fromEntries(Object.keys(headers).map((key) => [key, ""])), + headers: undefined, + }; +} + +function redactForPublicDiagnostics(value, depth = 0) { + if (depth > 6) return "[max-depth]"; + if (Array.isArray(value)) return value.map((item) => redactForPublicDiagnostics(item, depth + 1)); + if (!isPlainObject(value)) return value; + const out = {}; + for (const [key, item] of Object.entries(value)) { + if (/token|secret|password|authorization|cookie|api[_-]?key/i.test(key)) { + out[key] = ""; + continue; + } + out[key] = redactForPublicDiagnostics(item, depth + 1); + } + return out; +} + function mergeSurfaceContexts(...contexts) { const out = {}; for (const context of contexts) { @@ -2188,6 +2554,13 @@ function sanitizeDispatchCommand(payload) { function getRequestOwner(req) { const userId = optionalString(req.headers["x-nodedc-user-id"] || req.query.userId); const email = normalizeEmail(req.headers["x-nodedc-user-email"] || req.query.email); + const role = normalizeKey(req.headers["x-nodedc-user-role"] || req.query.role); + const groups = uniqueStrings( + String(req.headers["x-nodedc-user-groups"] || req.query.groups || "") + .split(/[\n,;]+/) + .map((item) => item.trim()) + .filter(Boolean) + ); if (!userId && !email) { throw badRequest("ai_workspace_owner_required"); } @@ -2195,6 +2568,8 @@ function getRequestOwner(req) { key: email ? `email:${email}` : `user:${userId}`, userId, email, + role: role || "", + groups, }; } @@ -2203,6 +2578,8 @@ function publicOwner(owner) { ownerKey: owner.key, userId: owner.userId, email: owner.email, + role: owner.role || "", + groups: Array.isArray(owner.groups) ? owner.groups : [], }; } @@ -2568,6 +2945,111 @@ function isDeployedPublicHubUrl(value) { } } +function parseEntitlementAdapters() { + const adapters = []; + collectEntitlementAdapters(adapters, parseJsonEnv( + process.env.AI_WORKSPACE_ENTITLEMENT_ADAPTERS_JSON || + process.env.NDC_AI_WORKSPACE_ENTITLEMENT_ADAPTERS_JSON || + "" + )); + collectEntitlementAdapter(adapters, "ops", { + url: process.env.AI_WORKSPACE_OPS_ENTITLEMENT_URL || process.env.NDC_AI_WORKSPACE_OPS_ENTITLEMENT_URL, + token: process.env.AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN || process.env.NDC_AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN, + required: process.env.AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED || process.env.NDC_AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED, + }); + collectEntitlementAdapter(adapters, "engine", { + url: process.env.AI_WORKSPACE_ENGINE_ENTITLEMENT_URL || process.env.NDC_AI_WORKSPACE_ENGINE_ENTITLEMENT_URL, + token: process.env.AI_WORKSPACE_ENGINE_ENTITLEMENT_TOKEN || process.env.NDC_AI_WORKSPACE_ENGINE_ENTITLEMENT_TOKEN, + required: process.env.AI_WORKSPACE_ENGINE_ENTITLEMENT_REQUIRED || process.env.NDC_AI_WORKSPACE_ENGINE_ENTITLEMENT_REQUIRED, + }); + + const byAppId = new Map(); + for (const adapter of adapters) { + byAppId.set(adapter.appId, adapter); + } + return Array.from(byAppId.values()); +} + +function parseJsonEnv(value) { + const text = optionalString(value); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function collectEntitlementAdapters(target, value) { + if (Array.isArray(value)) { + for (const item of value) collectEntitlementAdapter(target, "", item); + return; + } + if (!isPlainObject(value)) return; + for (const [appId, adapter] of Object.entries(value)) { + collectEntitlementAdapter(target, appId, adapter); + } +} + +function collectEntitlementAdapter(target, defaultAppId, value) { + const adapter = sanitizeEntitlementAdapter(value, defaultAppId); + if (adapter) target.push(adapter); +} + +function sanitizeEntitlementAdapter(value, defaultAppId = "") { + if (!isPlainObject(value)) return null; + const appId = normalizeKey(value.appId || value.app_id || defaultAppId); + const url = cleanHttpEndpoint(value.url || value.endpoint); + if (!appId || !url) return null; + const tokenEnv = optionalString(value.tokenEnv || value.token_env); + const rawToken = optionalString(value.token || value.bearerToken || value.bearer_token) + || (tokenEnv ? optionalString(process.env[tokenEnv]) : null) + || optionalString(process.env.NODEDC_INTERNAL_ACCESS_TOKEN) + || optionalString(process.env.NODEDC_PLATFORM_SERVICE_TOKEN) + || ""; + const authorization = optionalString(value.authorization || value.Authorization) + || (rawToken + ? rawToken.match(/^Bearer\s+/i) ? rawToken : `Bearer ${rawToken}` + : ""); + return { + id: optionalString(value.id) || appId, + appId, + title: optionalString(value.title || value.appTitle || value.app_title), + url, + authorization, + headers: sanitizeAdapterHeaders(value.headers), + required: value.required === true || isTruthy(value.required), + timeoutMs: sanitizeInteger(value.timeoutMs || value.timeout_ms, 5000, 500, 30000), + }; +} + +function sanitizeAdapterHeaders(value) { + if (!isPlainObject(value)) return {}; + const headers = {}; + for (const [key, rawValue] of Object.entries(value)) { + const headerName = optionalString(key); + const headerValue = optionalString(rawValue); + if (!headerName || !headerValue || /[\r\n\0]/.test(headerName) || /[\r\n\0]/.test(headerValue)) continue; + if (/^authorization$/i.test(headerName)) continue; + headers[headerName] = headerValue; + } + return headers; +} + +function cleanHttpEndpoint(value) { + const text = optionalString(value); + 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 readConfig() { const databaseUrl = process.env.DATABASE_URL || @@ -2610,6 +3092,7 @@ function readConfig() { process.env.NODEDC_INTERNAL_ACCESS_TOKEN, process.env.NODEDC_PLATFORM_SERVICE_TOKEN, ]), + entitlementAdapters: parseEntitlementAdapters(), }; } diff --git a/services/ai-workspace-assistant/src/templates/install-windows.ps1 b/services/ai-workspace-assistant/src/templates/install-windows.ps1 index 485507c..0ccd8a6 100644 --- a/services/ai-workspace-assistant/src/templates/install-windows.ps1 +++ b/services/ai-workspace-assistant/src/templates/install-windows.ps1 @@ -765,6 +765,7 @@ const FINAL_MESSAGE_PHASES = new Set(['final', 'final_answer', 'answer']) const BRIDGE_FILE = fileURLToPath(import.meta.url) const BRIDGE_DIR = path.dirname(BRIDGE_FILE) const NDC_AGENT_CODEX_HOME = path.resolve(process.env.AI_BRIDGE_NDC_AGENT_CODEX_HOME || path.join(BRIDGE_DIR, 'codex-home-ndc-agent-core')) +const RUN_CODEX_HOME_ROOT = path.resolve(process.env.AI_BRIDGE_RUN_CODEX_HOME_ROOT || path.join(BRIDGE_DIR, 'codex-home-runs')) const DEFAULT_NDC_AGENT_MCP_API_BASE = 'http://127.0.0.1:3001/api/ndc-agent-mcp' const HUB_URL = String(process.env.AI_BRIDGE_HUB_URL || '').trim() const HUB_URLS = parseHubUrls(process.env.AI_BRIDGE_HUB_URLS || '', HUB_URL) @@ -1047,6 +1048,8 @@ 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 runProfilePolicyPrompt = String(runProfile.policyPrompt || '').trim() const userMessage = String(payload?.message || '').trim() const history = payload?.resume ? [] : normalizeHistory(payload?.history || []) const isNdcAgentCore = String(context.modeId || '').trim() === 'ndc-agent-core' @@ -1142,6 +1145,10 @@ function buildPrompt(payload) { 'System context guard:', guardInstruction, ] : [], + ...runProfilePolicyPrompt ? [ + '', + runProfilePolicyPrompt, + ] : [], '', ...history.length ? [ 'Recent conversation from AI Workspace:', @@ -1455,8 +1462,12 @@ function buildResumeArgs(sessionId) { return [...base, 'resume', clean, '-'] } +function isPlainObject(value) { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} + function isNdcAgentCorePayload(payload) { - const context = payload?.context && typeof payload.context === 'object' ? payload.context : {} + const context = isPlainObject(payload?.context) ? payload.context : {} return String(context.modeId || '').trim() === 'ndc-agent-core' } @@ -1564,6 +1575,124 @@ function tomlString(value) { return JSON.stringify(String(value || '')) } +function tomlKey(value) { + const key = String(value || '').trim() + if (/^[A-Za-z0-9_-]+$/.test(key)) return key + return JSON.stringify(key) +} + +function cleanMcpServerName(value) { + const text = String(value || '').trim().replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') + return text.slice(0, 80) +} + +function cleanRunDirectoryName(value) { + const text = String(value || '').trim().replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^_+|_+$/g, '') + return (text || `run-${Date.now()}`).slice(0, 120) +} + +function positiveInteger(value, fallback, min = 1, max = 600) { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return fallback + return Math.max(min, Math.min(max, Math.trunc(parsed))) +} + +function runProfileFromPayload(payload) { + return isPlainObject(payload?.runProfile) ? payload.runProfile : {} +} + +function runProfileMcpServers(payload) { + const runProfile = runProfileFromPayload(payload) + const toolProfile = isPlainObject(runProfile.toolProfile) ? runProfile.toolProfile : {} + const rawServers = Array.isArray(toolProfile.mcpServers) ? toolProfile.mcpServers : [] + const byName = new Map() + for (const raw of rawServers) { + const server = sanitizeRunMcpServer(raw) + if (!server) continue + byName.set(server.serverName, server) + } + return Array.from(byName.values()) +} + +function sanitizeRunMcpServer(raw) { + if (!isPlainObject(raw) || raw.enabled === false) return null + const serverName = cleanMcpServerName(raw.serverName || raw.server_name || raw.name) + const url = String(raw.url || '').trim() + if (!serverName || !isHttpUrl(url)) return null + return { + serverName, + url, + required: raw.required === true, + startupTimeoutSec: positiveInteger(raw.startupTimeoutSec || raw.startup_timeout_sec, 20, 1, 120), + toolTimeoutSec: positiveInteger(raw.toolTimeoutSec || raw.tool_timeout_sec, 60, 1, 600), + httpHeaders: sanitizeRunMcpHeaders(raw.httpHeaders || raw.http_headers || raw.headers), + } +} + +function sanitizeRunMcpHeaders(value) { + if (!isPlainObject(value)) return {} + const out = {} + for (const [key, item] of Object.entries(value)) { + const headerName = String(key || '').trim() + const headerValue = String(item ?? '').trim() + if (!headerName || !headerValue) continue + if (/[\r\n\0]/.test(headerName) || /[\r\n\0]/.test(headerValue)) continue + out[headerName] = headerValue + } + return out +} + +function runtimeCodexConfig() { + return [ + 'approval_policy = "never"', + 'sandbox_mode = "danger-full-access"', + ].join('\n') +} + +function dynamicMcpServerConfig(server) { + const lines = [ + `[mcp_servers.${tomlKey(server.serverName)}]`, + `url = ${tomlString(server.url)}`, + 'enabled = true', + `required = ${server.required ? 'true' : 'false'}`, + `startup_timeout_sec = ${positiveInteger(server.startupTimeoutSec, 20, 1, 120)}`, + `tool_timeout_sec = ${positiveInteger(server.toolTimeoutSec, 60, 1, 600)}`, + ] + const headers = isPlainObject(server.httpHeaders) ? server.httpHeaders : {} + const headerEntries = Object.entries(headers) + if (headerEntries.length) { + lines.push('', `[mcp_servers.${tomlKey(server.serverName)}.http_headers]`) + for (const [key, value] of headerEntries) { + lines.push(`${tomlKey(key)} = ${tomlString(value)}`) + } + } + return lines.join('\n') +} + +function dynamicMcpConfig(servers) { + return (Array.isArray(servers) ? servers : []) + .map(dynamicMcpServerConfig) + .filter(Boolean) + .join('\n\n') +} + +function ndcAgentMcpServerConfig(mcpContext, cwd) { + const ndcConfig = [ + '[mcp_servers.ndc_agent_core]', + `command = ${tomlString('node')}`, + `args = [${tomlString(NDC_AGENT_MCP_SERVER)}]`, + 'startup_timeout_sec = 20', + 'tool_timeout_sec = 120', + ].join('\n') + return `${ndcConfig}\n${ndcAgentMcpEnvConfig(mcpContext, cwd)}` +} + +function dynamicCodexHomePath(payload, needsNdcAgentCore) { + const runProfile = runProfileFromPayload(payload) + const runId = cleanRunDirectoryName(runProfile.runId || payload?.requestId || payload?.threadId || '') + return path.join(RUN_CODEX_HOME_ROOT, needsNdcAgentCore ? `ndc-${runId}` : runId) +} + function stripTomlSection(raw, sectionName) { const section = String(sectionName || '').trim() if (!section) return String(raw || '') @@ -1717,43 +1846,43 @@ function ndcAgentMcpEnvConfig(mcpContext, cwd) { ].join('\n') } -async function prepareNdcAgentCodexHome(mcpContext = {}, cwd = CODEX_CWD) { - await fs.mkdir(NDC_AGENT_CODEX_HOME, { recursive: true }) - await copyIfExists(path.join(CODEX_HOME, 'auth.json'), path.join(NDC_AGENT_CODEX_HOME, 'auth.json')) - const opsMcpConfig = await readOptionalOpsMcpConfig() - const runtimeConfig = [ - 'approval_policy = "never"', - 'sandbox_mode = "danger-full-access"', - ].join('\n') - const ndcConfig = [ - '[mcp_servers.ndc_agent_core]', - `command = ${tomlString('node')}`, - `args = [${tomlString(NDC_AGENT_MCP_SERVER)}]`, - 'startup_timeout_sec = 20', - 'tool_timeout_sec = 120', - ].join('\n') +async function prepareRunCodexHome({ payload = {}, cwd = CODEX_CWD, mcpContext = null, dynamicMcpServers = [] } = {}) { + const needsNdcAgentCore = isPlainObject(mcpContext) + const hasDynamicMcp = Array.isArray(dynamicMcpServers) && dynamicMcpServers.length > 0 + const codexHome = hasDynamicMcp + ? dynamicCodexHomePath(payload, needsNdcAgentCore) + : 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 config = [ - runtimeConfig, - `${ndcConfig}\n${ndcAgentMcpEnvConfig(mcpContext, cwd)}`, - opsMcpConfig, + runtimeCodexConfig(), + needsNdcAgentCore ? ndcAgentMcpServerConfig(mcpContext, cwd) : '', + hasDynamicMcp ? dynamicMcpConfig(dynamicMcpServers) : legacyOpsMcpConfig, ].filter(Boolean).join('\n\n') - await fs.writeFile(path.join(NDC_AGENT_CODEX_HOME, 'config.toml'), `${config}\n`, 'utf8') - return NDC_AGENT_CODEX_HOME + await fs.writeFile(path.join(codexHome, 'config.toml'), `${config}\n`, 'utf8') + return codexHome } async function codexInvocationForPayload(baseArgs, payload, cwd) { - if (!isNdcAgentCorePayload(payload)) return { args: baseArgs, env: {} } - const mcpContext = buildNdcAgentMcpContext(payload, cwd) - const codexHome = await prepareNdcAgentCodexHome(mcpContext, cwd) + const dynamicMcpServers = runProfileMcpServers(payload) + const needsNdcAgentCore = isNdcAgentCorePayload(payload) + if (!needsNdcAgentCore && !dynamicMcpServers.length) return { args: baseArgs, env: {}, dynamicMcpServerNames: [] } + const mcpContext = needsNdcAgentCore ? buildNdcAgentMcpContext(payload, cwd) : null + const codexHome = await prepareRunCodexHome({ payload, cwd, mcpContext, dynamicMcpServers }) return { args: baseArgs, env: { CODEX_HOME: codexHome, - NDC_AGENT_MCP_ROOT: CODEX_CWD, - NDC_AGENT_MCP_CONTEXT: JSON.stringify(mcpContext), - NDC_AGENT_MCP_API_BASE_URL: mcpContext.ndcAgentMcpApiBaseUrl, - AI_BRIDGE_PAIRING_CODE: PAIRING_CODE, + ...(needsNdcAgentCore ? { + NDC_AGENT_MCP_ROOT: CODEX_CWD, + NDC_AGENT_MCP_CONTEXT: JSON.stringify(mcpContext), + NDC_AGENT_MCP_API_BASE_URL: mcpContext.ndcAgentMcpApiBaseUrl, + AI_BRIDGE_PAIRING_CODE: PAIRING_CODE, + } : {}), }, + dynamicMcpServerNames: dynamicMcpServers.map((server) => server.serverName), + codexHome, } } @@ -2218,6 +2347,12 @@ async function runCodexForPayload(prompt, payload, onEvent = () => {}, cwd = COD }) } catch {} } + if (Array.isArray(invocation.dynamicMcpServerNames) && invocation.dynamicMcpServerNames.length) { + onEvent({ + kind: 'run_profile', + message: `Dynamic run profile MCP servers: ${invocation.dynamicMcpServerNames.join(', ')}`, + }) + } const control = { requestId: meta?.requestId, threadId: payload?.threadId, @@ -2453,6 +2588,10 @@ async function handleBridgeCommand(command, payload = {}, onEvent = () => {}, me codexAuthPath: runtime.authPath, codexBin: CODEX_BIN, codexArgs: CODEX_ARGS, + dynamicRunProfile: { + enabled: true, + codexHomeRoot: RUN_CODEX_HOME_ROOT, + }, cwd: CODEX_CWD, hubMode: hubState.mode, hubConnected: hubState.connected,