feat(ai-workspace): add local relay profiles
This commit is contained in:
@@ -6,6 +6,7 @@ COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY src ./src
|
||||
COPY ontology-core /ontology-core
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 18082
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app/services/ai-workspace-assistant
|
||||
|
||||
COPY ai-workspace-assistant/package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY ai-workspace-assistant/src ./src
|
||||
COPY ai-workspace-assistant/docs ./docs
|
||||
COPY ontology-core /app/services/ontology-core
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 18082
|
||||
|
||||
CMD ["npm", "start"]
|
||||
@@ -53,6 +53,49 @@ POST /api/ai-workspace/assistant/v1/threads/:threadId/messages
|
||||
GET /api/ai-workspace/assistant/v1/threads/:threadId/messages
|
||||
```
|
||||
|
||||
Assistant actions:
|
||||
|
||||
```text
|
||||
POST /api/ai-workspace/assistant/v1/actions
|
||||
```
|
||||
|
||||
This is the platform entrypoint for ontology-backed assistant tool calls. Natural-language chat dispatch still goes through the selected assistant model/executor first; `ontology-core` is the deterministic policy/action layer the assistant calls after it has interpreted the user's intent.
|
||||
|
||||
Request shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"phase": "preview",
|
||||
"input": {
|
||||
"actionId": "hub.user.block",
|
||||
"targetUserId": "user_123",
|
||||
"confirmed": true,
|
||||
"idempotencyKey": "hub.user.block:user_admin:user_123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For write execution, first call `phase=preview`, show the returned `action.confirmation` to the user, then call `phase=execute` with `confirmationToken`. The final mutation still goes through the app-owned Launcher admin API guards; this service only routes the registered action.
|
||||
|
||||
Launcher action gateway configuration:
|
||||
|
||||
```text
|
||||
NDC_ONTOLOGY_LAUNCHER_BASE_URL=http://launcher.local.nodedc
|
||||
NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN=...
|
||||
```
|
||||
|
||||
If `NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN` is not set, the service falls back to `NODEDC_INTERNAL_ACCESS_TOKEN`.
|
||||
|
||||
The first HUB action ids exposed to assistants are:
|
||||
|
||||
- `hub.user.read_admin_summary`
|
||||
- `hub.invite.list_pending`
|
||||
- `hub.access_request.list_pending`
|
||||
|
||||
Read actions can be executed without user confirmation after the assistant has selected the structured action. Privileged/write actions still require explicit preview, user confirmation, and execute.
|
||||
|
||||
App BFFs should pass active scope facts in action input (`clientId`, `coreAssistantRole`, `membershipRole`, `membershipStatus`) so policy stays data-driven instead of prompt-driven.
|
||||
|
||||
Supported initial surfaces:
|
||||
|
||||
- `engine`
|
||||
@@ -71,6 +114,8 @@ Supported initial tool packs:
|
||||
|
||||
Every dispatch builds an `ai-workspace.run-profile.v1` payload. The raw profile is sent only to the bridge worker; public API responses and run metadata keep MCP headers redacted.
|
||||
|
||||
The canonical cross-surface execution contract is [AI Workspace Protocol v1](./docs/AI_WORKSPACE_PROTOCOL_V1.md). It defines layer ownership, run profile shape, tool manifests, worker boundaries, Hub boundaries, app adapter rules, and the migration path from the current scaffold.
|
||||
|
||||
The worker uses `runProfile.toolProfile.mcpServers` to create an isolated per-run `CODEX_HOME/config.toml`. Install-time MCP config remains only as a legacy fallback when no dynamic profile is available.
|
||||
|
||||
Runtime deploy readiness is tracked in [TEST_MATRIX.md](./TEST_MATRIX.md). The `smoke:run-profile` script covers the entitlement adapter to run profile contract and public secret redaction.
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
# AI Workspace Protocol v1
|
||||
|
||||
Status: draft canonical contract.
|
||||
Date: 2026-06-19
|
||||
Owner: NODE.DC AI Workspace Assistant.
|
||||
|
||||
This document fixes the platform contract for cross-application assistant
|
||||
execution. It exists to prevent assistant capabilities from spreading through
|
||||
prompt-only rules, app-specific shortcuts, worker code, or transport glue.
|
||||
|
||||
## Goal
|
||||
|
||||
AI Workspace Protocol v1 defines how a user request from any NODE.DC surface
|
||||
is converted into a safe, auditable capability call across HUB, OPS, ENGINE,
|
||||
and future platform applications.
|
||||
|
||||
The protocol must support hundreds of functional blocks without changing the
|
||||
worker for every new feature.
|
||||
|
||||
## Layer Ownership
|
||||
|
||||
### Client Surface
|
||||
|
||||
Examples: ENGINE UI, OPS UI, HUB UI, future platform apps.
|
||||
|
||||
Owns:
|
||||
|
||||
- visible UI state;
|
||||
- current surface context;
|
||||
- active selected objects;
|
||||
- user-facing preview and confirmation UI.
|
||||
|
||||
Must not own:
|
||||
|
||||
- final permission enforcement;
|
||||
- platform action registry;
|
||||
- backend actor identity;
|
||||
- raw internal tokens.
|
||||
|
||||
### AI Workspace Assistant
|
||||
|
||||
Owns:
|
||||
|
||||
- assistant thread/session metadata;
|
||||
- selected executor;
|
||||
- run profile construction;
|
||||
- tool manifest construction;
|
||||
- action routing entrypoint;
|
||||
- policy prompts derived from structured contracts;
|
||||
- run-scoped entitlement resolution;
|
||||
- public redaction of run diagnostics.
|
||||
|
||||
Must be the only platform layer that decides which assistant tools are exposed
|
||||
to a run.
|
||||
|
||||
### Ontology Core
|
||||
|
||||
Owns:
|
||||
|
||||
- canonical entities and relations;
|
||||
- aliases and resolver rules;
|
||||
- assistant access policy;
|
||||
- assistant action registry;
|
||||
- risk policy;
|
||||
- confirmation policy;
|
||||
- app-owned adapter descriptors.
|
||||
|
||||
Must not own:
|
||||
|
||||
- HUB user source data;
|
||||
- OPS card source data;
|
||||
- ENGINE workflow source data;
|
||||
- transport routing;
|
||||
- worker execution.
|
||||
|
||||
### AI Hub
|
||||
|
||||
Owns:
|
||||
|
||||
- remote worker rendezvous;
|
||||
- pairing;
|
||||
- dispatch relay;
|
||||
- message delivery;
|
||||
- proxying to trusted internal services when configured.
|
||||
|
||||
Must not own:
|
||||
|
||||
- assistant action catalog;
|
||||
- business permissions;
|
||||
- prompt policy;
|
||||
- domain-specific decisions.
|
||||
|
||||
### Worker
|
||||
|
||||
Owns:
|
||||
|
||||
- local/remote executor runtime;
|
||||
- Codex/model process launch;
|
||||
- per-run isolated config;
|
||||
- tool relay based on the received manifest;
|
||||
- event/result streaming.
|
||||
|
||||
Must not own:
|
||||
|
||||
- HUB/OPS/ENGINE action IDs as hardcoded product knowledge;
|
||||
- role rules;
|
||||
- app-level permission decisions;
|
||||
- long-lived platform tokens;
|
||||
- app-owned adapters.
|
||||
|
||||
### App API / MCP
|
||||
|
||||
Examples: Launcher/HUB API, OPS Gateway, ENGINE API.
|
||||
|
||||
Owns:
|
||||
|
||||
- source-of-truth data;
|
||||
- object-level ACL;
|
||||
- final enforcement;
|
||||
- app-native audit;
|
||||
- app-owned adapter routes.
|
||||
|
||||
Must reject invalid or forged requests even when AI Workspace allowed the plan.
|
||||
|
||||
## Canonical Flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["User request"] --> C["Client surface context"]
|
||||
C --> A["AI Workspace Assistant"]
|
||||
A --> O["Ontology Core: resolve capability and policy"]
|
||||
O --> A
|
||||
A --> P["Run profile + tool manifest"]
|
||||
P --> W["Worker or in-process executor"]
|
||||
W --> R["Tool relay"]
|
||||
R --> API["App API / MCP"]
|
||||
API --> AUD["Audit + result"]
|
||||
AUD --> A
|
||||
A --> C
|
||||
```
|
||||
|
||||
Read actions may execute after the assistant selects a structured action.
|
||||
Write or privileged actions must use preview first, then explicit confirmation,
|
||||
then execute. Destructive actions are not exposed as assistant capabilities.
|
||||
|
||||
## Run Profile
|
||||
|
||||
Schema version:
|
||||
|
||||
```text
|
||||
ai-workspace.run-profile.v1
|
||||
```
|
||||
|
||||
Required top-level fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "ai-workspace.run-profile.v1",
|
||||
"runId": "uuid",
|
||||
"createdAt": "2026-06-19T00:00:00.000Z",
|
||||
"expiresAt": "2026-06-19T00:10:00.000Z",
|
||||
"audience": "ai-workspace-worker",
|
||||
"requestId": "uuid-or-nonce",
|
||||
"owner": {},
|
||||
"sourceSurface": "engine",
|
||||
"activeContext": {},
|
||||
"toolProfile": {},
|
||||
"policyPrompt": "derived text",
|
||||
"integrity": {}
|
||||
}
|
||||
```
|
||||
|
||||
Required rules:
|
||||
|
||||
- `expiresAt` must be short-lived.
|
||||
- `requestId` must be unique enough for replay protection.
|
||||
- `audience` must name the intended recipient class.
|
||||
- `integrity` must contain a server-side hash/signature before production use.
|
||||
- public diagnostics must redact tokens, headers, secrets, and raw internal URLs.
|
||||
|
||||
## Actor Claims
|
||||
|
||||
Actor claims are backend facts, not user text.
|
||||
|
||||
Canonical owner shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"ownerKey": "email:dcctouch@gmail.com",
|
||||
"userId": "platform-user-id",
|
||||
"email": "dcctouch@gmail.com",
|
||||
"role": "root-admin",
|
||||
"groups": ["nodedc:superadmin"]
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- client-supplied actor headers are not trusted directly;
|
||||
- trusted App BFF or AI Workspace must resolve actor claims;
|
||||
- target App API must verify the actor again against app-native state;
|
||||
- app adapters may receive claims, but never treat them as the only authority.
|
||||
|
||||
## Tool Profile
|
||||
|
||||
Schema version:
|
||||
|
||||
```text
|
||||
ai-workspace.tool-profile.v1
|
||||
```
|
||||
|
||||
Shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "ai-workspace.tool-profile.v1",
|
||||
"enabledToolPacks": ["engine", "ops", "ndc-agent-core"],
|
||||
"mcpServers": [],
|
||||
"assistantActions": {
|
||||
"schemaVersion": "ai-workspace.assistant-actions.v1",
|
||||
"endpoint": "/api/ai-workspace/assistant/v1/actions",
|
||||
"actionIds": ["hub.user.read_admin_summary"],
|
||||
"phases": ["preview", "execute"],
|
||||
"tokenRef": "run-token:assistant-actions"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- action IDs come from AI Workspace Assistant + Ontology Core, not from ENGINE
|
||||
local routes or worker source code;
|
||||
- worker may read and expose a generic tool based on this manifest;
|
||||
- worker must not hardcode the product meaning of an action ID;
|
||||
- `tokenRef` or server-side relay is preferred over embedding raw bearer tokens;
|
||||
- if a bearer token is unavoidable during a scaffold phase, it must be
|
||||
short-lived, run-scoped, redacted from public output, and removed before
|
||||
production.
|
||||
|
||||
## Assistant Action Call
|
||||
|
||||
Endpoint:
|
||||
|
||||
```text
|
||||
POST /api/ai-workspace/assistant/v1/actions
|
||||
```
|
||||
|
||||
Preview request:
|
||||
|
||||
```json
|
||||
{
|
||||
"phase": "preview",
|
||||
"input": {
|
||||
"actionId": "hub.user.block",
|
||||
"targetUserId": "user_123",
|
||||
"idempotencyKey": "hub.user.block:actor:target"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Execute request:
|
||||
|
||||
```json
|
||||
{
|
||||
"phase": "execute",
|
||||
"input": {
|
||||
"actionId": "hub.user.block",
|
||||
"targetUserId": "user_123",
|
||||
"confirmationToken": "preview-token",
|
||||
"idempotencyKey": "hub.user.block:actor:target"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- the assistant selects `actionId` after understanding natural language;
|
||||
- preview must return a human-readable expected effect;
|
||||
- write execution must require a matching confirmation token;
|
||||
- write execution must include an idempotency key;
|
||||
- app-owned adapter must re-check permissions;
|
||||
- delete/hard destructive requests resolve to forbidden with safe alternatives.
|
||||
|
||||
## Worker Protocol
|
||||
|
||||
The worker receives a run payload and starts the configured executor.
|
||||
|
||||
Worker responsibilities:
|
||||
|
||||
- create isolated per-run config;
|
||||
- expose only tools described by the run profile;
|
||||
- relay tool calls to AI Workspace or app MCP endpoints;
|
||||
- stream model/tool events back to AI Workspace;
|
||||
- redact runtime secrets from logs.
|
||||
|
||||
Worker non-responsibilities:
|
||||
|
||||
- it does not decide whether a HUB admin can block a user;
|
||||
- it does not know all HUB/OPS/ENGINE action IDs in source code;
|
||||
- it does not store internal platform tokens after a run;
|
||||
- it does not bypass AI Workspace with raw app API calls.
|
||||
|
||||
## Hub Protocol
|
||||
|
||||
AI Hub may proxy:
|
||||
|
||||
- worker dispatch;
|
||||
- worker events;
|
||||
- assistant action requests to AI Workspace Assistant.
|
||||
|
||||
AI Hub must not:
|
||||
|
||||
- build action catalogs;
|
||||
- evaluate assistant access policy;
|
||||
- mutate app data directly;
|
||||
- accept public actor claims as final truth.
|
||||
|
||||
## App Adapter Rules
|
||||
|
||||
Every app adapter must define:
|
||||
|
||||
- owning app: `hub`, `ops`, `engine`, or another platform app;
|
||||
- allowed method/path list;
|
||||
- read/write/destructive classification;
|
||||
- required native scope or role;
|
||||
- idempotency behavior for writes;
|
||||
- audit event output;
|
||||
- confirmation mode;
|
||||
- safe alternatives for forbidden actions.
|
||||
|
||||
HUB examples:
|
||||
|
||||
- read admin summary;
|
||||
- list pending invites;
|
||||
- list pending access requests;
|
||||
- block/unblock user;
|
||||
- disable membership;
|
||||
- change NDC Core Assistant role.
|
||||
|
||||
OPS examples:
|
||||
|
||||
- create/update card;
|
||||
- append report/comment;
|
||||
- read project/card context;
|
||||
- update structured card blocks.
|
||||
|
||||
ENGINE examples:
|
||||
|
||||
- read workflow graph;
|
||||
- select workflow/agent node;
|
||||
- inspect workflow errors;
|
||||
- change workflow ACL only through ENGINE-owned adapter.
|
||||
|
||||
## Security Requirements
|
||||
|
||||
Production requirements:
|
||||
|
||||
- all cross-service traffic uses TLS or an equivalent private secure channel;
|
||||
- no long-lived internal token in worker, prompt, UI, or public diagnostics;
|
||||
- run tokens are scoped by run, audience, capability, and expiry;
|
||||
- run profile integrity is hash/signed by AI Workspace;
|
||||
- writes require idempotency keys;
|
||||
- privileged writes require preview and confirmation token;
|
||||
- target App API performs final authorization;
|
||||
- all writes produce audit events;
|
||||
- app APIs reject forged actor headers from public clients;
|
||||
- secret redaction is covered by smoke tests.
|
||||
|
||||
## Localhost vs Production
|
||||
|
||||
Localhost and production must use the same logical protocol:
|
||||
|
||||
```text
|
||||
client -> AI Workspace Assistant -> Ontology Core -> AI Hub/Worker -> App API
|
||||
```
|
||||
|
||||
Allowed differences:
|
||||
|
||||
- URLs;
|
||||
- network transport;
|
||||
- token issuer;
|
||||
- deployment topology.
|
||||
|
||||
Not allowed:
|
||||
|
||||
- separate local-only business rules;
|
||||
- Engine-only action catalog;
|
||||
- worker-only permission model;
|
||||
- production-only policy path that contradicts local behavior.
|
||||
|
||||
## Migration From Current Scaffold
|
||||
|
||||
Current scaffold deviations to remove:
|
||||
|
||||
1. ENGINE local bridge owns fallback `ASSISTANT_ACTION_TOOL_PROFILE`.
|
||||
2. Worker prompt includes concrete HUB action IDs.
|
||||
3. Worker receives or stores assistant action gateway bearer token.
|
||||
4. Engine MCP server knows gateway URL/token and owner header rules.
|
||||
5. AI Hub forwards owner headers without signed actor context.
|
||||
|
||||
Migration steps:
|
||||
|
||||
1. Move assistant action profile source of truth to AI Workspace Assistant.
|
||||
2. Generate action IDs from Ontology Core action registry.
|
||||
3. Replace raw token passthrough with run-scoped token or server-side relay.
|
||||
4. Convert worker action support to generic manifest-driven relay.
|
||||
5. Add protocol tests for boundary violations.
|
||||
6. Mark current scaffold paths as legacy/fallback until removed.
|
||||
|
||||
## Boundary Tests
|
||||
|
||||
Minimum tests before production rollout:
|
||||
|
||||
- public run profile never exposes bearer tokens or internal secrets;
|
||||
- worker source does not contain product action ID allowlists;
|
||||
- ENGINE local routes do not contain HUB action registry;
|
||||
- AI Hub does not evaluate business policy;
|
||||
- forged public actor headers are rejected by app API;
|
||||
- write execution without confirmation token is blocked;
|
||||
- write replay with same idempotency key is safe;
|
||||
- delete actions are unavailable and resolve to safe alternatives;
|
||||
- local and production profiles share the same schema.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
The protocol is ready for product slices when:
|
||||
|
||||
- adding a new assistant function changes Ontology Core and the target app
|
||||
adapter, not the worker;
|
||||
- AI Workspace Assistant remains the only run profile source of truth;
|
||||
- Hub remains transport-only;
|
||||
- worker can be installed once and keep working as capabilities grow;
|
||||
- target apps remain final enforcement owners;
|
||||
- no prompt-only rule is required for safety-critical behavior.
|
||||
@@ -44,6 +44,14 @@ const adapterPayload = {
|
||||
const appGrants = normalizeEntitlementAdapterAppGrants(adapterPayload, adapter);
|
||||
const mcpServers = runProfileMcpServersFromAppGrants({}, appGrants);
|
||||
const appGrantSummary = summarizeRunAppGrants({ appGrants });
|
||||
const assistantActions = {
|
||||
schemaVersion: "ai-workspace.assistant-actions.v1",
|
||||
endpoint: "/api/ai-workspace/hub/v1/assistant-relays/local-dev/actions",
|
||||
gatewayUrl: "https://ai-hub.nodedc.ru/api/ai-workspace/hub/v1/assistant-relays/local-dev/actions",
|
||||
gatewayToken: SECRET_TOKEN,
|
||||
actionIds: ["hub.access_request.list_pending", "hub.user.read_admin_summary"],
|
||||
phases: ["preview", "execute"],
|
||||
};
|
||||
const runProfile = {
|
||||
schemaVersion: "ai-workspace.run-profile.v1",
|
||||
runId: randomUUID(),
|
||||
@@ -71,6 +79,7 @@ const runProfile = {
|
||||
mcpServers,
|
||||
mcpServerNames: mcpServers.map((server) => server.serverName),
|
||||
requiredMcpServerNames: mcpServers.filter((server) => server.required).map((server) => server.serverName),
|
||||
assistantActions,
|
||||
},
|
||||
diagnostics: {
|
||||
schemaVersion: "ai-workspace.run-profile.diagnostics.v1",
|
||||
@@ -106,6 +115,10 @@ assert.equal(publicProfile.toolProfile.mcpServers[0].serverName, "nodedc_ops_age
|
||||
assert.equal(publicProfile.toolProfile.mcpServers[0].httpHeaders.Authorization, "<redacted>");
|
||||
assert.equal(publicProfile.toolProfile.mcpServers[0].httpHeaders.Accept, "<redacted>");
|
||||
assert.equal(publicProfile.toolProfile.mcpServers[0].headers, undefined);
|
||||
assert.equal(publicProfile.toolProfile.assistantActions.endpoint, "/api/ai-workspace/hub/v1/assistant-relays/local-dev/actions");
|
||||
assert.equal(publicProfile.toolProfile.assistantActions.gatewayUrl, "https://ai-hub.nodedc.ru/api/ai-workspace/hub/v1/assistant-relays/local-dev/actions");
|
||||
assert.equal(publicProfile.toolProfile.assistantActions.gatewayToken, "<redacted>");
|
||||
assert.deepEqual(publicProfile.toolProfile.assistantActions.actionIds, ["hub.access_request.list_pending", "hub.user.read_admin_summary"]);
|
||||
assert.equal(JSON.stringify(publicProfile).includes(SECRET_TOKEN), false);
|
||||
assert.match(runProfile.diagnostics.profileHash, /^[a-f0-9]{16}$/);
|
||||
|
||||
@@ -114,7 +127,9 @@ console.log(JSON.stringify({
|
||||
checks: [
|
||||
"adapter_grant_normalized",
|
||||
"token_scoped_ops_mcp_in_run_profile",
|
||||
"assistant_action_relay_in_run_profile",
|
||||
"public_run_profile_redacts_mcp_headers",
|
||||
"public_run_profile_redacts_assistant_action_gateway_token",
|
||||
"stable_public_profile_hash",
|
||||
],
|
||||
mcpServerNames: runProfile.toolProfile.mcpServerNames,
|
||||
@@ -276,6 +291,7 @@ function redactRunProfile(runProfile) {
|
||||
appGrants: redactForPublicDiagnostics(runProfile.appGrants),
|
||||
toolProfile: {
|
||||
...(isPlainObject(runProfile.toolProfile) ? runProfile.toolProfile : {}),
|
||||
assistantActions: redactAssistantActions(runProfile.toolProfile?.assistantActions),
|
||||
mcpServers: Array.isArray(runProfile.toolProfile?.mcpServers)
|
||||
? runProfile.toolProfile.mcpServers.map(redactMcpServer)
|
||||
: [],
|
||||
@@ -283,6 +299,14 @@ function redactRunProfile(runProfile) {
|
||||
};
|
||||
}
|
||||
|
||||
function redactAssistantActions(value) {
|
||||
if (!isPlainObject(value)) return value;
|
||||
return {
|
||||
...value,
|
||||
...(value.gatewayToken ? { gatewayToken: "<redacted>" } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function redactMcpServer(server) {
|
||||
if (!isPlainObject(server)) return {};
|
||||
const headers = isPlainObject(server.httpHeaders) ? server.httpHeaders : {};
|
||||
|
||||
@@ -3,6 +3,8 @@ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { Pool } from "pg";
|
||||
import { handleAssistantCallerRequest } from "../../ontology-core/src/assistant-action-caller.mjs";
|
||||
import { loadCatalog } from "../../ontology-core/src/catalog.mjs";
|
||||
|
||||
const SUPPORTED_SURFACES = new Set(["engine", "ops", "global"]);
|
||||
const SUPPORTED_EXECUTOR_TYPES = new Set(["codex-remote", "ndc-agent-core"]);
|
||||
@@ -12,8 +14,25 @@ const SUPPORTED_THREAD_STATES = new Set(["active", "archived"]);
|
||||
const SUPPORTED_MESSAGE_ROLES = new Set(["user", "assistant", "system", "tool"]);
|
||||
const SUPPORTED_TOOL_PACKS = new Set(["engine", "ops", "ndc-agent-core", "deploy", "docs"]);
|
||||
const SUPPORTED_RUN_STATUSES = new Set(["running", "completed", "failed", "timeout"]);
|
||||
const ASSISTANT_ACTION_TOOL_PROFILE_BASE = {
|
||||
schemaVersion: "ai-workspace.assistant-actions.v1",
|
||||
endpoint: "/api/ai-workspace/assistant/v1/actions",
|
||||
modelFlow: "interpret_user_intent_then_call_structured_action",
|
||||
phases: ["preview", "execute"],
|
||||
safety: {
|
||||
read: "execute_after_structured_action_selection",
|
||||
write: "preview_then_explicit_user_confirmation_then_execute",
|
||||
destructive: "forbidden",
|
||||
},
|
||||
};
|
||||
const ASSISTANT_ACTION_CACHE_TTL_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_ACTION_CACHE_TTL_MS || 30_000);
|
||||
const ASSISTANT_ACTION_RELAY_POLL_TIMEOUT_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_POLL_TIMEOUT_MS || 25_000);
|
||||
const ASSISTANT_ACTION_RELAY_POLL_IDLE_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_POLL_IDLE_MS || 500);
|
||||
const ASSISTANT_ACTION_RELAY_ERROR_BACKOFF_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_ERROR_BACKOFF_MS || 5_000);
|
||||
const BRIDGE_RUN_MIRROR_POLL_MS = 1200;
|
||||
const BRIDGE_RUN_MIRROR_MAX_MS = 12 * 60 * 60 * 1000;
|
||||
let assistantActionIdsCache = { loadedAt: 0, actionIds: [] };
|
||||
let assistantActionRelayStopping = false;
|
||||
|
||||
const config = readConfig();
|
||||
const pool = new Pool({ connectionString: config.databaseUrl, max: config.databasePoolSize });
|
||||
@@ -47,6 +66,115 @@ app.patch("/api/ai-workspace/assistant/v1/settings", requireInternalApi, asyncRo
|
||||
res.json({ ok: true, owner: publicOwner(owner), settings: publicOwnerSettings(settings) });
|
||||
}));
|
||||
|
||||
app.post("/api/ai-workspace/assistant/v1/run-profile", requireInternalApi, asyncRoute(async (req, res) => {
|
||||
const owner = getRequestOwner(req);
|
||||
const command = sanitizeRunProfileCommand(req.body);
|
||||
const ownerSettings = await getOwnerSettings(owner);
|
||||
const executorId = command.selectedExecutorId || ownerSettings.selectedExecutorId;
|
||||
if (!executorId) {
|
||||
res.status(400).json({ ok: false, error: "ai_workspace_executor_required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const executor = await getExecutor(owner, executorId);
|
||||
if (!executor) {
|
||||
res.status(404).json({ ok: false, error: "ai_workspace_executor_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const thread = {
|
||||
id: command.threadId || randomUUID(),
|
||||
title: command.threadTitle || "AI Workspace Bridge",
|
||||
originSurface: command.originSurface || optionalString(command.context.surface) || "engine",
|
||||
activeContext: {},
|
||||
enabledToolPacks: command.enabledToolPacks,
|
||||
};
|
||||
const bridgePayload = {
|
||||
context: command.context,
|
||||
enabledToolPacks: command.enabledToolPacks,
|
||||
workspacePath: command.workspacePath || executor.workspacePath || "",
|
||||
client: command.client,
|
||||
};
|
||||
const runProfile = await buildRunProfile({ owner, thread, executor, ownerSettings, bridgePayload });
|
||||
res.json({ ok: true, owner: publicOwner(owner), runProfile });
|
||||
}));
|
||||
|
||||
app.post("/api/ai-workspace/assistant/v1/executors/:executorId/dispatch", requireInternalApi, asyncRoute(async (req, res) => {
|
||||
const owner = getRequestOwner(req);
|
||||
const executorId = sanitizeUuid(req.params.executorId, "executorId");
|
||||
const executor = await getExecutor(owner, executorId);
|
||||
if (!executor) {
|
||||
res.status(404).json({ ok: false, error: "ai_workspace_executor_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const command = sanitizeBridgeDispatchCommand({ ...(isPlainObject(req.body) ? req.body : {}), selectedExecutorId: executorId });
|
||||
if (!command.message) {
|
||||
res.status(400).json({ ok: false, error: "ai_workspace_dispatch_requires_user_message" });
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerSettings = await getOwnerSettings(owner);
|
||||
const thread = {
|
||||
id: command.threadId || randomUUID(),
|
||||
title: command.threadTitle || "AI Workspace Bridge",
|
||||
originSurface: command.originSurface || optionalString(command.context.surface) || "engine",
|
||||
activeContext: {},
|
||||
enabledToolPacks: command.enabledToolPacks,
|
||||
};
|
||||
const bridgePayload = {
|
||||
threadId: thread.id,
|
||||
threadTitle: thread.title,
|
||||
workspacePath: command.workspacePath || executor.workspacePath || "",
|
||||
message: command.message,
|
||||
displayMessage: command.displayMessage || command.message,
|
||||
publicUserMessage: command.publicUserMessage || command.message,
|
||||
resume: command.resume,
|
||||
history: command.history,
|
||||
context: command.context,
|
||||
enabledToolPacks: command.enabledToolPacks,
|
||||
client: command.client,
|
||||
};
|
||||
bridgePayload.runProfile = await buildRunProfile({ owner, thread, executor, ownerSettings, bridgePayload });
|
||||
const bridge = await dispatchExecutorMessage(executor, bridgePayload);
|
||||
res.json({
|
||||
ok: true,
|
||||
owner: publicOwner(owner),
|
||||
executorId: executor.id,
|
||||
bridge,
|
||||
runProfile: redactRunProfile(bridgePayload.runProfile),
|
||||
});
|
||||
}));
|
||||
|
||||
app.post("/api/ai-workspace/assistant/v1/executors/:executorId/stop", requireInternalApi, asyncRoute(async (req, res) => {
|
||||
const owner = getRequestOwner(req);
|
||||
const executorId = sanitizeUuid(req.params.executorId, "executorId");
|
||||
const executor = await getExecutor(owner, executorId);
|
||||
if (!executor) {
|
||||
res.status(404).json({ ok: false, error: "ai_workspace_executor_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const command = sanitizeBridgeStopCommand(req.body);
|
||||
if (!command.requestId && !command.threadId) {
|
||||
res.status(400).json({ ok: false, error: "ai_workspace_stop_target_empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
const bridge = await dispatchExecutorStop(executor, command);
|
||||
res.json({
|
||||
ok: true,
|
||||
owner: publicOwner(owner),
|
||||
executorId: executor.id,
|
||||
bridge,
|
||||
});
|
||||
}));
|
||||
|
||||
app.post("/api/ai-workspace/assistant/v1/actions", requireInternalApi, asyncRoute(async (req, res) => {
|
||||
const owner = getRequestOwner(req);
|
||||
res.json(await executeAssistantActionRequest(req.body, owner));
|
||||
}));
|
||||
|
||||
app.get("/api/ai-workspace/assistant/v1/executors", requireInternalApi, asyncRoute(async (req, res) => {
|
||||
const owner = getRequestOwner(req);
|
||||
const [executors, settings] = await Promise.all([
|
||||
@@ -397,6 +525,7 @@ await recoverBridgeRunMirrors();
|
||||
|
||||
httpServer.listen(config.port, "0.0.0.0", () => {
|
||||
console.log(`NODE.DC AI Workspace Assistant listening on http://0.0.0.0:${config.port}`);
|
||||
startAssistantActionRelayLoop();
|
||||
});
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
@@ -1017,6 +1146,62 @@ async function dispatchExecutorMessage(executor, payload) {
|
||||
};
|
||||
}
|
||||
|
||||
async function dispatchExecutorStop(executor, payload) {
|
||||
if (executor.connectionMode === "hub" || executor.pairingCode) {
|
||||
const pairingCode = cleanPairingCode(executor.pairingCode);
|
||||
if (!pairingCode) {
|
||||
const error = new Error("bridge_pairing_code_empty");
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
const response = await hubRequestJson(
|
||||
`/api/ai-workspace/hub/v1/agents/${encodeURIComponent(pairingCode)}/dispatch`,
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
command: "stop",
|
||||
payload,
|
||||
timeoutMs: 30000,
|
||||
quiet: false,
|
||||
},
|
||||
},
|
||||
10000
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
accepted: true,
|
||||
requestId: optionalString(response.requestId),
|
||||
targetRequestId: payload.requestId,
|
||||
threadId: payload.threadId,
|
||||
mode: "hub",
|
||||
};
|
||||
}
|
||||
|
||||
const url = bridgeCommandUrl(executor.endpoint, "stop");
|
||||
if (!url) {
|
||||
const error = new Error("bridge_endpoint_required");
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
const response = await fetchJson(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
10000
|
||||
);
|
||||
return {
|
||||
ok: response?.ok !== false,
|
||||
accepted: false,
|
||||
response,
|
||||
targetRequestId: payload.requestId,
|
||||
threadId: payload.threadId,
|
||||
mode: "direct",
|
||||
};
|
||||
}
|
||||
|
||||
async function createBridgeRun({ owner, thread, executor, bridge, payload }) {
|
||||
const requestId = optionalString(bridge?.requestId);
|
||||
if (!requestId || bridge?.accepted !== true || bridge?.mode !== "hub") return null;
|
||||
@@ -1447,6 +1632,7 @@ async function buildRunProfile({ owner, thread, executor, ownerSettings, bridgeP
|
||||
const appGrants = summarizeRunAppGrants({ appGrants: grantResolution.appGrants });
|
||||
const mcpServers = runProfileMcpServersFromAppGrants(ownerSettings, grantResolution.appGrants);
|
||||
const mcpServerNames = mcpServers.map((server) => server.serverName).filter(Boolean);
|
||||
const assistantActions = await assistantActionToolProfileForRun();
|
||||
const requiredMcpServerNames = mcpServers
|
||||
.filter((server) => server.required === true)
|
||||
.map((server) => server.serverName)
|
||||
@@ -1464,6 +1650,8 @@ async function buildRunProfile({ owner, thread, executor, ownerSettings, bridgeP
|
||||
entitlementAdapters: grantResolution.diagnostics,
|
||||
mcpServerNames,
|
||||
requiredMcpServerNames,
|
||||
assistantActionIds: assistantActions.actionIds,
|
||||
assistantActionGatewayConfigured: Boolean(assistantActions.gatewayUrl && assistantActions.gatewayToken),
|
||||
missingContext: Array.isArray(context.missingContext)
|
||||
? context.missingContext.map(optionalString).filter(Boolean)
|
||||
: [],
|
||||
@@ -1490,14 +1678,164 @@ async function buildRunProfile({ owner, thread, executor, ownerSettings, bridgeP
|
||||
mcpServers,
|
||||
mcpServerNames,
|
||||
requiredMcpServerNames,
|
||||
assistantActions,
|
||||
},
|
||||
policyPrompt: buildRunProfilePolicyPrompt({ context, diagnostics }),
|
||||
policyPrompt: buildRunProfilePolicyPrompt({ context, diagnostics, assistantActions }),
|
||||
diagnostics,
|
||||
};
|
||||
runProfile.diagnostics.profileHash = runProfileHash(runProfile);
|
||||
return runProfile;
|
||||
}
|
||||
|
||||
async function assistantActionToolProfileForRun() {
|
||||
const gatewayUrl = assistantActionGatewayUrlForRun();
|
||||
const gatewayToken = optionalString(
|
||||
process.env.NDC_AI_WORKSPACE_ASSISTANT_ACTION_GATEWAY_TOKEN ||
|
||||
config.hubInternalAccessToken ||
|
||||
config.internalAccessTokens[0] ||
|
||||
""
|
||||
);
|
||||
const actionIds = await assistantActionIdsForRun();
|
||||
return {
|
||||
...ASSISTANT_ACTION_TOOL_PROFILE_BASE,
|
||||
endpoint: actionGatewayEndpointPath(gatewayUrl) || ASSISTANT_ACTION_TOOL_PROFILE_BASE.endpoint,
|
||||
actionIds,
|
||||
...(gatewayUrl ? { gatewayUrl } : {}),
|
||||
...(gatewayToken ? { gatewayToken } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function assistantActionGatewayUrlForRun() {
|
||||
const explicit = cleanHttpEndpoint(process.env.NDC_AI_WORKSPACE_ASSISTANT_ACTION_GATEWAY_URL);
|
||||
if (explicit) return explicit;
|
||||
if (config.assistantActionRelayEnabled && config.assistantActionRelayId && config.hubInternalHttpUrl) {
|
||||
return cleanHttpEndpoint(
|
||||
`${config.hubInternalHttpUrl.replace(/\/+$/, "")}/api/ai-workspace/hub/v1/assistant-relays/${encodeURIComponent(config.assistantActionRelayId)}/actions`
|
||||
);
|
||||
}
|
||||
return cleanHttpEndpoint(config.hubInternalHttpUrl || httpUrlFromWebSocketUrl(config.hubWebSocketUrl));
|
||||
}
|
||||
|
||||
function actionGatewayEndpointPath(gatewayUrl) {
|
||||
try {
|
||||
const url = new URL(gatewayUrl);
|
||||
return `${url.pathname}${url.search || ""}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function assistantActionIdsForRun() {
|
||||
const now = Date.now();
|
||||
if (
|
||||
assistantActionIdsCache.actionIds.length &&
|
||||
now - assistantActionIdsCache.loadedAt < ASSISTANT_ACTION_CACHE_TTL_MS
|
||||
) {
|
||||
return assistantActionIdsCache.actionIds;
|
||||
}
|
||||
|
||||
try {
|
||||
const catalog = await loadCatalog();
|
||||
const actionIds = (Array.isArray(catalog?.assistantActions?.actions) ? catalog.assistantActions.actions : [])
|
||||
.filter((action) => (
|
||||
isPlainObject(action) &&
|
||||
optionalString(action.id) &&
|
||||
action.adapterStatus === "implemented" &&
|
||||
action.confirmationMode !== "forbidden"
|
||||
))
|
||||
.map((action) => optionalString(action.id))
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
assistantActionIdsCache = { loadedAt: now, actionIds };
|
||||
return actionIds;
|
||||
} catch {
|
||||
assistantActionIdsCache = { loadedAt: now, actionIds: [] };
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function startAssistantActionRelayLoop() {
|
||||
if (!config.assistantActionRelayEnabled) return;
|
||||
if (!config.assistantActionRelayId || !config.hubInternalHttpUrl || !config.hubInternalAccessToken) {
|
||||
console.warn("Assistant action relay disabled: relay id, hub URL, or hub token is missing.");
|
||||
return;
|
||||
}
|
||||
void assistantActionRelayLoop();
|
||||
}
|
||||
|
||||
async function assistantActionRelayLoop() {
|
||||
while (!assistantActionRelayStopping) {
|
||||
try {
|
||||
const payload = await hubRequestJson(
|
||||
`/api/ai-workspace/hub/v1/assistant-relays/${encodeURIComponent(config.assistantActionRelayId)}/poll`,
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
limit: 5,
|
||||
timeoutMs: ASSISTANT_ACTION_RELAY_POLL_TIMEOUT_MS,
|
||||
},
|
||||
},
|
||||
ASSISTANT_ACTION_RELAY_POLL_TIMEOUT_MS + 5000
|
||||
);
|
||||
const calls = Array.isArray(payload?.calls) ? payload.calls : [];
|
||||
for (const call of calls) {
|
||||
void handleAssistantActionRelayCall(call).catch((error) => {
|
||||
console.error("Assistant action relay call failed:", errorMessage(error));
|
||||
});
|
||||
}
|
||||
await delay(ASSISTANT_ACTION_RELAY_POLL_IDLE_MS);
|
||||
} catch (error) {
|
||||
if (!assistantActionRelayStopping) {
|
||||
console.error("Assistant action relay poll failed:", errorMessage(error));
|
||||
await delay(ASSISTANT_ACTION_RELAY_ERROR_BACKOFF_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssistantActionRelayCall(call) {
|
||||
const callId = optionalString(call?.callId);
|
||||
if (!callId) return;
|
||||
|
||||
let status = 200;
|
||||
let body = null;
|
||||
try {
|
||||
const owner = getRequestOwner({ headers: relayCallHeaders(call?.headers), query: {} });
|
||||
body = await executeAssistantActionRequest(isPlainObject(call?.payload) ? call.payload : {}, owner);
|
||||
} catch (error) {
|
||||
status = Number(error?.status || 500);
|
||||
body = { ok: false, error: errorMessage(error) };
|
||||
}
|
||||
|
||||
await hubRequestJson(
|
||||
`/api/ai-workspace/hub/v1/assistant-relays/${encodeURIComponent(config.assistantActionRelayId)}/results/${encodeURIComponent(callId)}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
status,
|
||||
body,
|
||||
},
|
||||
},
|
||||
10000
|
||||
);
|
||||
}
|
||||
|
||||
function relayCallHeaders(value) {
|
||||
const headers = {};
|
||||
if (!isPlainObject(value)) return headers;
|
||||
for (const [key, rawValue] of Object.entries(value)) {
|
||||
const name = optionalString(key)?.toLowerCase();
|
||||
const text = optionalString(rawValue);
|
||||
if (!name || !text) continue;
|
||||
headers[name] = text;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms || 0))));
|
||||
}
|
||||
|
||||
async function resolveRunAppGrants({ owner, context, ownerSettings }) {
|
||||
const metadata = isPlainObject(ownerSettings?.metadata) ? ownerSettings.metadata : {};
|
||||
const staticAppGrants = isPlainObject(metadata.appGrants) ? metadata.appGrants : {};
|
||||
@@ -1709,7 +2047,7 @@ function summarizeRunAppGrants(metadata) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildRunProfilePolicyPrompt({ context, diagnostics }) {
|
||||
function buildRunProfilePolicyPrompt({ context, diagnostics, assistantActions }) {
|
||||
const lines = [
|
||||
"AI Workspace dynamic run profile:",
|
||||
`- source surface: ${diagnostics.sourceSurface}`,
|
||||
@@ -1719,6 +2057,11 @@ function buildRunProfilePolicyPrompt({ context, diagnostics }) {
|
||||
`- entitlement source: ${diagnostics.entitlementAdapters?.source || "settings"}`,
|
||||
`- enabled tool packs: ${diagnostics.enabledToolPacks.length ? diagnostics.enabledToolPacks.join(", ") : "none"}`,
|
||||
`- MCP servers available in this run: ${diagnostics.mcpServerNames.length ? diagnostics.mcpServerNames.join(", ") : "none"}`,
|
||||
`- assistant action ids available: ${Array.isArray(assistantActions?.actionIds) ? assistantActions.actionIds.join(", ") : "none"}`,
|
||||
"- Interpret the user's natural-language request first; call assistant actions only after selecting a structured action id.",
|
||||
"- Read assistant actions may execute after structured action selection. Privileged/write assistant actions require preview, explicit user confirmation, then execute.",
|
||||
"- Ops card actions advertised in this run are valid assistant actions: use ops.card.list_recent for reading cards, ops.card.create for creating cards, and ops.card.add_comment for comments instead of refusing because direct Ops MCP tools are absent.",
|
||||
"- Destructive assistant actions are forbidden; offer safe alternatives such as block/disable instead of delete.",
|
||||
"- MCP tokens and headers are runtime secrets and must never be printed in public answers.",
|
||||
];
|
||||
const opsContext = isPlainObject(context?.contexts?.ops) ? context.contexts.ops : {};
|
||||
@@ -1754,19 +2097,29 @@ function runProfileHash(runProfile) {
|
||||
|
||||
function redactRunProfile(runProfile) {
|
||||
if (!isPlainObject(runProfile)) return null;
|
||||
const toolProfile = isPlainObject(runProfile.toolProfile) ? runProfile.toolProfile : {};
|
||||
return {
|
||||
...runProfile,
|
||||
targetContexts: redactForPublicDiagnostics(runProfile.targetContexts),
|
||||
appGrants: redactForPublicDiagnostics(runProfile.appGrants),
|
||||
toolProfile: {
|
||||
...(isPlainObject(runProfile.toolProfile) ? runProfile.toolProfile : {}),
|
||||
mcpServers: Array.isArray(runProfile.toolProfile?.mcpServers)
|
||||
? runProfile.toolProfile.mcpServers.map(redactMcpServer)
|
||||
...toolProfile,
|
||||
assistantActions: redactAssistantActions(toolProfile.assistantActions),
|
||||
mcpServers: Array.isArray(toolProfile.mcpServers)
|
||||
? toolProfile.mcpServers.map(redactMcpServer)
|
||||
: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function redactAssistantActions(value) {
|
||||
if (!isPlainObject(value)) return value;
|
||||
return {
|
||||
...value,
|
||||
...(value.gatewayToken ? { gatewayToken: "<redacted>" } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function redactMcpServer(server) {
|
||||
if (!isPlainObject(server)) return {};
|
||||
const headers = isPlainObject(server.httpHeaders) ? server.httpHeaders : {};
|
||||
@@ -2520,12 +2873,62 @@ function sanitizeOwnerSettingsCommand(payload) {
|
||||
return command;
|
||||
}
|
||||
|
||||
function sanitizeRunProfileCommand(payload) {
|
||||
const source = isPlainObject(payload) ? payload : {};
|
||||
const context = isPlainObject(source.context) ? source.context : {};
|
||||
const client = isPlainObject(source.client) ? source.client : {};
|
||||
const selectedExecutorId = source.selectedExecutorId || source.selected_executor_id || null;
|
||||
const toolPacks = mergeToolPacks(
|
||||
source.enabledToolPacks,
|
||||
source.enabled_tool_packs,
|
||||
context.enabledToolPacks,
|
||||
);
|
||||
return {
|
||||
selectedExecutorId: selectedExecutorId ? sanitizeUuid(selectedExecutorId, "selectedExecutorId") : null,
|
||||
threadId: optionalString(source.threadId || source.thread_id),
|
||||
threadTitle: optionalString(source.threadTitle || source.thread_title),
|
||||
workspacePath: optionalString(source.workspacePath || source.workspace_path),
|
||||
originSurface: sanitizeSurface(source.originSurface || source.origin_surface || context.sourceSurface || context.surface || client.surface || "engine"),
|
||||
context,
|
||||
client,
|
||||
enabledToolPacks: toolPacks,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeBridgeDispatchCommand(payload) {
|
||||
const source = isPlainObject(payload) ? payload : {};
|
||||
const command = sanitizeRunProfileCommand(source);
|
||||
return {
|
||||
...command,
|
||||
message: optionalString(source.message || source.content),
|
||||
displayMessage: optionalString(source.displayMessage || source.display_message),
|
||||
publicUserMessage: optionalString(source.publicUserMessage || source.public_user_message),
|
||||
resume: source.resume === true,
|
||||
history: Array.isArray(source.history)
|
||||
? source.history.slice(-40).map((item) => ({
|
||||
role: optionalString(item?.role),
|
||||
text: optionalString(item?.text || item?.content),
|
||||
})).filter((item) => item.role && item.text)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeThreadKind(value) {
|
||||
const text = normalizeKey(value || "all");
|
||||
if (text === "shared" || text === "remote" || text === "all") return text;
|
||||
return "all";
|
||||
}
|
||||
|
||||
function sanitizeBridgeStopCommand(payload) {
|
||||
const source = isPlainObject(payload) ? payload : {};
|
||||
return {
|
||||
requestId: optionalString(source.requestId || source.request_id),
|
||||
threadId: optionalString(source.threadId || source.thread_id),
|
||||
reason: optionalString(source.reason) || "user_stop",
|
||||
client: isPlainObject(source.client) ? source.client : {},
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeMessageCommand(payload) {
|
||||
const source = isPlainObject(payload) ? payload : {};
|
||||
return {
|
||||
@@ -2573,6 +2976,111 @@ function getRequestOwner(req) {
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeAssistantActionCommand(body, owner) {
|
||||
const source = isPlainObject(body) ? body : {};
|
||||
const phase = normalizeKey(source.phase || source.mode || "preview");
|
||||
if (!["preview", "dry-run", "execute"].includes(phase)) {
|
||||
throw badRequest("assistant_action_phase_invalid");
|
||||
}
|
||||
|
||||
const sourceInput = isPlainObject(source.input) ? source.input : source;
|
||||
const input = { ...sourceInput };
|
||||
delete input.phase;
|
||||
delete input.mode;
|
||||
delete input.input;
|
||||
|
||||
const ownerContext = assistantOwnerContext(owner, sourceInput);
|
||||
input.actorUserId = owner.userId || null;
|
||||
input.actorEmail = owner.email || null;
|
||||
input.actorSubject = owner.key;
|
||||
input.groups = owner.groups;
|
||||
input.launcherGlobalRole = ownerContext.launcherGlobalRole;
|
||||
input.membershipRole = ownerContext.membershipRole;
|
||||
input.membershipStatus = ownerContext.membershipStatus;
|
||||
input.launcherUserStatus = ownerContext.launcherUserStatus;
|
||||
input.assistantRole = ownerContext.assistantRole;
|
||||
|
||||
const confirmationToken = optionalString(source.confirmationToken || sourceInput.confirmationToken || source.confirmation?.token);
|
||||
if (confirmationToken) input.confirmationToken = confirmationToken;
|
||||
|
||||
return { phase, input };
|
||||
}
|
||||
|
||||
async function executeAssistantActionRequest(body, owner) {
|
||||
const command = sanitizeAssistantActionCommand(body, owner);
|
||||
let opsGatewayToken = "";
|
||||
if (command.phase === "execute") {
|
||||
const ownerSettings = await getOwnerSettings(owner);
|
||||
const grantResolution = await resolveRunAppGrants({
|
||||
owner,
|
||||
context: isPlainObject(ownerSettings.activeContext) ? ownerSettings.activeContext : {},
|
||||
ownerSettings,
|
||||
});
|
||||
opsGatewayToken = opsGatewayTokenFromAppGrants(ownerSettings, grantResolution.appGrants, config.opsGatewayBaseUrl);
|
||||
}
|
||||
const action = await handleAssistantCallerRequest(command, {
|
||||
baseUrl: config.ontologyLauncherBaseUrl,
|
||||
launcherInternalToken: config.launcherInternalAccessToken,
|
||||
opsGatewayBaseUrl: config.opsGatewayBaseUrl,
|
||||
opsGatewayToken,
|
||||
opsEntitlementUrl: config.opsEntitlementUrl,
|
||||
opsEntitlementAuthorization: config.opsEntitlementAuthorization,
|
||||
defaultOpsWorkspaceSlug: config.defaultOpsWorkspaceSlug,
|
||||
defaultOpsProjectId: config.defaultOpsProjectId,
|
||||
});
|
||||
return { ok: true, owner: publicOwner(owner), action: publicAssistantCallerResult(action) };
|
||||
}
|
||||
|
||||
function urlOrigin(value) {
|
||||
try {
|
||||
return new URL(value).origin;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function opsGatewayTokenFromAppGrants(ownerSettings, appGrants, opsGatewayBaseUrl) {
|
||||
const servers = runProfileMcpServersFromAppGrants(ownerSettings, appGrants);
|
||||
const targetOrigin = urlOrigin(opsGatewayBaseUrl);
|
||||
const preferred = servers.find((server) => (
|
||||
server.appId === "ops" &&
|
||||
(!targetOrigin || urlOrigin(server.url) === targetOrigin)
|
||||
)) || servers.find((server) => server.appId === "ops");
|
||||
return bearerTokenFromHeaders(preferred?.httpHeaders);
|
||||
}
|
||||
|
||||
function assistantOwnerContext(owner, sourceInput = {}) {
|
||||
const groups = new Set(owner.groups || []);
|
||||
const ownerRole = normalizeKey(owner.role);
|
||||
const isRoot = ownerRole === "root-admin" || ownerRole === "root_admin" || groups.has("nodedc:superadmin") || groups.has("nodedc:launcher:admin");
|
||||
const membershipRole = launcherMembershipRoleFromOwner(ownerRole, sourceInput);
|
||||
const assistantRole = assistantRoleFromOwner({ isRoot, sourceInput });
|
||||
|
||||
return {
|
||||
assistantRole,
|
||||
launcherGlobalRole: isRoot ? "root_admin" : ownerRole.replace(/-/g, "_"),
|
||||
membershipRole,
|
||||
membershipStatus: optionalString(sourceInput.membershipStatus) || "active",
|
||||
launcherUserStatus: optionalString(sourceInput.launcherUserStatus || sourceInput.globalStatus) || "active",
|
||||
};
|
||||
}
|
||||
|
||||
function assistantRoleFromOwner({ isRoot, sourceInput }) {
|
||||
if (isRoot) return "admin";
|
||||
const value = normalizeKey(sourceInput.assistantRole || sourceInput.coreAssistantRole);
|
||||
if (value === "assistant-admin" || value === "admin") return "admin";
|
||||
if (value === "assistant-blocked" || value === "blocked") return "blocked";
|
||||
return "member";
|
||||
}
|
||||
|
||||
function launcherMembershipRoleFromOwner(ownerRole, sourceInput = {}) {
|
||||
const role = ownerRole.replace(/-/g, "_");
|
||||
if (["client_owner", "client_admin", "member"].includes(role)) return role;
|
||||
const sourceRole = normalizeKey(sourceInput.membershipRole).replace(/-/g, "_");
|
||||
if (["client_owner", "client_admin", "member"].includes(sourceRole)) return sourceRole;
|
||||
return "member";
|
||||
}
|
||||
|
||||
function publicOwner(owner) {
|
||||
return {
|
||||
ownerKey: owner.key,
|
||||
@@ -2583,6 +3091,12 @@ function publicOwner(owner) {
|
||||
};
|
||||
}
|
||||
|
||||
function publicAssistantCallerResult(result) {
|
||||
if (!isPlainObject(result)) return result;
|
||||
const { raw: _raw, ...publicResult } = result;
|
||||
return publicResult;
|
||||
}
|
||||
|
||||
function publicOwnerSettings(settings) {
|
||||
if (!settings || !isPlainObject(settings)) return settings;
|
||||
return {
|
||||
@@ -2874,6 +3388,11 @@ function isTruthy(value) {
|
||||
return text === "1" || text === "true" || text === "yes" || text === "on";
|
||||
}
|
||||
|
||||
function isFalsy(value) {
|
||||
const text = optionalString(value)?.toLowerCase() || "";
|
||||
return text === "0" || text === "false" || text === "no" || text === "off";
|
||||
}
|
||||
|
||||
function normalizeEmail(value) {
|
||||
const text = optionalString(value);
|
||||
return text ? text.toLowerCase() : null;
|
||||
@@ -3051,6 +3570,7 @@ function cleanHttpEndpoint(value) {
|
||||
}
|
||||
|
||||
function readConfig() {
|
||||
const nodedcEnv = normalizeKey(process.env.NODEDC_ENV || process.env.NDC_ENV || process.env.NODE_ENV || "");
|
||||
const databaseUrl =
|
||||
process.env.DATABASE_URL ||
|
||||
process.env.AI_WORKSPACE_ASSISTANT_DATABASE_URL ||
|
||||
@@ -3070,8 +3590,44 @@ function readConfig() {
|
||||
optionalString(process.env.NDC_AI_WORKSPACE_HUB_TOKEN) ||
|
||||
"";
|
||||
const sharedInternalAccessToken = optionalString(process.env.NODEDC_INTERNAL_ACCESS_TOKEN) || "";
|
||||
const defaultOntologyLauncherBaseUrl =
|
||||
process.env.NODE_ENV === 'production' ? 'http://launcher:5173' : 'http://launcher.local.nodedc'
|
||||
const ontologyLauncherBaseUrl =
|
||||
optionalString(process.env.NDC_ONTOLOGY_LAUNCHER_BASE_URL) ||
|
||||
optionalString(process.env.NDC_LAUNCHER_BASE_URL) ||
|
||||
optionalString(process.env.NDC_LAUNCHER_INTERNAL_URL) ||
|
||||
optionalString(process.env.LAUNCHER_INTERNAL_URL) ||
|
||||
optionalString(process.env.LAUNCHER_BASE_URL) ||
|
||||
defaultOntologyLauncherBaseUrl;
|
||||
const launcherInternalAccessToken =
|
||||
optionalString(process.env.NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN) ||
|
||||
optionalString(process.env.LAUNCHER_INTERNAL_TOKEN) ||
|
||||
sharedInternalAccessToken;
|
||||
const assistantActionRelayId =
|
||||
optionalString(process.env.AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID) ||
|
||||
optionalString(process.env.NDC_AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID) ||
|
||||
(nodedcEnv === "local" || nodedcEnv === "tunnel-local-e2e" ? "local-dev" : "");
|
||||
const assistantActionRelayEnabledRaw = optionalString(
|
||||
process.env.AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED ||
|
||||
process.env.NDC_AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED
|
||||
);
|
||||
const assistantActionRelayDefaultEnabled = Boolean(
|
||||
assistantActionRelayId &&
|
||||
(nodedcEnv === "local" || nodedcEnv === "tunnel-local-e2e") &&
|
||||
isDeployedPublicHubUrl(hubInternalHttpUrl)
|
||||
);
|
||||
const assistantActionRelayEnabled = assistantActionRelayEnabledRaw
|
||||
? isTruthy(assistantActionRelayEnabledRaw) && !isFalsy(assistantActionRelayEnabledRaw)
|
||||
: assistantActionRelayDefaultEnabled;
|
||||
const entitlementAdapters = parseEntitlementAdapters();
|
||||
const opsEntitlementAdapter = entitlementAdapters.find((adapter) => adapter.appId === "ops") || null;
|
||||
const opsGatewayBaseUrl =
|
||||
optionalString(process.env.AI_WORKSPACE_OPS_GATEWAY_BASE_URL) ||
|
||||
optionalString(process.env.NDC_AI_WORKSPACE_OPS_GATEWAY_BASE_URL) ||
|
||||
"";
|
||||
|
||||
return {
|
||||
nodedcEnv,
|
||||
port: Number(process.env.PORT || process.env.AI_WORKSPACE_ASSISTANT_PORT || "18082"),
|
||||
databaseUrl,
|
||||
databasePoolSize: Number(process.env.AI_WORKSPACE_ASSISTANT_DATABASE_POOL_SIZE || "10"),
|
||||
@@ -3079,6 +3635,21 @@ function readConfig() {
|
||||
hubInternalHttpUrl: hubInternalHttpUrl.replace(/\/+$/, ""),
|
||||
hubInternalAccessToken:
|
||||
explicitHubAccessToken || (isDeployedPublicHubUrl(hubInternalHttpUrl) ? "" : sharedInternalAccessToken),
|
||||
ontologyLauncherBaseUrl: ontologyLauncherBaseUrl.replace(/\/+$/, ""),
|
||||
launcherInternalAccessToken,
|
||||
opsGatewayBaseUrl: opsGatewayBaseUrl.replace(/\/+$/, ""),
|
||||
opsEntitlementUrl: opsEntitlementAdapter?.url || "",
|
||||
opsEntitlementAuthorization: opsEntitlementAdapter?.authorization || "",
|
||||
defaultOpsWorkspaceSlug:
|
||||
optionalString(process.env.AI_WORKSPACE_OPS_DEFAULT_WORKSPACE_SLUG) ||
|
||||
optionalString(process.env.NDC_AI_WORKSPACE_OPS_DEFAULT_WORKSPACE_SLUG) ||
|
||||
"",
|
||||
defaultOpsProjectId:
|
||||
optionalString(process.env.AI_WORKSPACE_OPS_DEFAULT_PROJECT_ID) ||
|
||||
optionalString(process.env.NDC_AI_WORKSPACE_OPS_DEFAULT_PROJECT_ID) ||
|
||||
"",
|
||||
assistantActionRelayEnabled,
|
||||
assistantActionRelayId,
|
||||
hubFallbackWebSocketUrls: String(
|
||||
process.env.AI_WORKSPACE_HUB_FALLBACK_URLS ||
|
||||
process.env.NDC_AI_WORKSPACE_HUB_FALLBACK_URLS ||
|
||||
@@ -3092,11 +3663,12 @@ function readConfig() {
|
||||
process.env.NODEDC_INTERNAL_ACCESS_TOKEN,
|
||||
process.env.NODEDC_PLATFORM_SERVICE_TOKEN,
|
||||
]),
|
||||
entitlementAdapters: parseEntitlementAdapters(),
|
||||
entitlementAdapters,
|
||||
};
|
||||
}
|
||||
|
||||
async function shutdown() {
|
||||
assistantActionRelayStopping = true;
|
||||
try {
|
||||
httpServer.close();
|
||||
await pool.end();
|
||||
|
||||
@@ -879,6 +879,26 @@ function cleanRunKey(value, limit = 160) {
|
||||
return String(value || '').trim().slice(0, limit)
|
||||
}
|
||||
|
||||
function terminateProcessTree(child, signal = 'SIGTERM') {
|
||||
if (!child) return
|
||||
if (process.platform === 'win32' && child.pid) {
|
||||
try {
|
||||
const args = ['/PID', String(child.pid), '/T']
|
||||
if (signal === 'SIGKILL') args.push('/F')
|
||||
const killer = spawn('taskkill', args, {
|
||||
windowsHide: true,
|
||||
stdio: 'ignore',
|
||||
shell: false,
|
||||
})
|
||||
killer.unref?.()
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
child.kill(signal)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function registerActiveCodexRun(control = {}, child, onEvent = () => {}) {
|
||||
const requestId = cleanRunKey(control.requestId, 120)
|
||||
const threadId = cleanRunKey(control.threadId, 160)
|
||||
@@ -898,14 +918,10 @@ function registerActiveCodexRun(control = {}, child, onEvent = () => {}) {
|
||||
try {
|
||||
run.onEvent({ kind: 'stopped', message: 'Codex stop requested.' })
|
||||
} catch {}
|
||||
try {
|
||||
run.child.kill('SIGTERM')
|
||||
} catch {}
|
||||
terminateProcessTree(run.child, 'SIGTERM')
|
||||
run.stopTimer = setTimeout(() => {
|
||||
if (run.closed) return
|
||||
try {
|
||||
run.child.kill('SIGKILL')
|
||||
} catch {}
|
||||
terminateProcessTree(run.child, 'SIGKILL')
|
||||
}, CODEX_STOP_GRACE_MS)
|
||||
return true
|
||||
},
|
||||
@@ -1049,6 +1065,13 @@ async function readConversations() {
|
||||
function buildPrompt(payload) {
|
||||
const context = payload?.context && typeof payload.context === 'object' ? payload.context : {}
|
||||
const runProfile = payload?.runProfile && typeof payload.runProfile === 'object' ? payload.runProfile : {}
|
||||
const toolProfile = runProfile.toolProfile && typeof runProfile.toolProfile === 'object' ? runProfile.toolProfile : {}
|
||||
const assistantActions = toolProfile.assistantActions && typeof toolProfile.assistantActions === 'object'
|
||||
? toolProfile.assistantActions
|
||||
: {}
|
||||
const assistantActionIds = Array.isArray(assistantActions.actionIds)
|
||||
? assistantActions.actionIds.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: []
|
||||
const runProfilePolicyPrompt = String(runProfile.policyPrompt || '').trim()
|
||||
const userMessage = String(payload?.message || '').trim()
|
||||
const history = payload?.resume ? [] : normalizeHistory(payload?.history || [])
|
||||
@@ -1116,7 +1139,7 @@ function buildPrompt(payload) {
|
||||
`- Selected agentNodeId: ${context.agentNodeId || 'unknown'}`,
|
||||
'- Read the second-level graph with GET /subworkflow?workflowId=<workflowId>&nodeId=<agentNodeId>.',
|
||||
'- Apply graph edits with POST /subworkflow/patch using JSON: { workflowId, nodeId, intent, operations }.',
|
||||
'- Prefer the MCP tools exposed by the ndc_agent_core server: ndc_get_context, ndc_get_subworkflow, ndc_search_nodes, ndc_get_node_definition, ndc_apply_subworkflow_patch, ndc_validate_subworkflow.',
|
||||
'- Prefer the MCP tools exposed by the ndc_agent_core server: ndc_get_context, ndc_get_subworkflow, ndc_search_nodes, ndc_get_node_definition, ndc_apply_subworkflow_patch, ndc_validate_subworkflow, assistant_action_call.',
|
||||
'- Do not use direct NDC core runtime MCP servers, local workflow files, or shell probes for graph edits in this mode.',
|
||||
'- Use only ndc_agent_core MCP tools for the selected second-level workflow and stop after a cancellation, fetch, or policy error with the exact tool name and error.',
|
||||
'- In public answers, use only NDC labels: NDC workflow, NDC node, NDC node type, NDC nodebase, and NDC Agent Core.',
|
||||
@@ -1125,6 +1148,15 @@ function buildPrompt(payload) {
|
||||
'- Prefer small patches and preserve existing node ids, node names, positions, parameters, and connections unless the user asks to change them.',
|
||||
'- After saving through the Engine API, the open Engine canvas refreshes from dc.subworkflow.json; never edit local files or use shell fallbacks for graph changes in this mode.',
|
||||
],
|
||||
'',
|
||||
'Assistant action routing contract:',
|
||||
`- Assistant action ids available in this run: ${assistantActionIds.length ? assistantActionIds.join(', ') : 'none'}.`,
|
||||
'- For Launcher/HUB/admin/access/users/invites/roles/service grants requests, use only the ndc_agent_core MCP tool assistant_action_call.',
|
||||
'- Do not use codex_apps readonly connectors, read_handoff, local files, shell search, logs, or workspace scans for Launcher/HUB live administrative data.',
|
||||
'- Use phase="execute" for read-only action calls after selecting the structured action id.',
|
||||
'- Use phase="preview" before any privileged/write action, ask for explicit confirmation, then use phase="execute" only after confirmation.',
|
||||
'- Useful read action ids when advertised: hub.access_request.list_pending, hub.invite.list_pending, hub.user.read_admin_summary.',
|
||||
'- If assistant_action_call is unavailable or the gateway returns an error, report that exact tool/error and stop; do not guess from local files.',
|
||||
] : [],
|
||||
...(isOpsMode || opsContext.opsWorkspaceSlug || opsContext.opsProjectId ? [
|
||||
'',
|
||||
@@ -1134,8 +1166,12 @@ function buildPrompt(payload) {
|
||||
`- Selected Ops project id: ${opsContext.opsProjectId || 'unknown'}`,
|
||||
`- Selected Ops project identifier: ${opsContext.opsProjectIdentifier || 'unknown'}`,
|
||||
'- Prefer the MCP tools exposed for NODE.DC Ops when creating, updating, moving, or reading tasks.',
|
||||
'- If assistant action ids include ops.card.list_recent, ops.card.create, or ops.card.add_comment, these are the canonical Ops card actions for this run.',
|
||||
'- Use assistant_action_call phase="execute" for Ops card reads after selecting ops.card.list_recent.',
|
||||
'- Use assistant_action_call phase="preview" before Ops card create/comment writes, ask for explicit confirmation, then call phase="execute" with the returned confirmation token.',
|
||||
'- Before writing Ops tasks, use the Ops MCP project/context tools when available and include a unique idempotency key for write tools.',
|
||||
'- If Ops MCP tools are unavailable in the Codex session, say that the Ops context is selected but the Ops MCP tools are unavailable.',
|
||||
'- If direct Ops MCP tools are unavailable but the Ops assistant action ids are advertised, do not refuse; route through assistant_action_call.',
|
||||
'- If neither direct Ops MCP tools nor Ops assistant action ids are available, say that the Ops context is selected but live Ops tools are unavailable.',
|
||||
'- Do not claim that an Engine workflow or Engine agent node is required for Ops task writes.',
|
||||
'- If the current request is about Ops tasks and Ops workspace/project are selected, treat that as the writable Ops target.',
|
||||
'- In public answers, use NODE.DC/Ops labels and never expose internal vendor names.',
|
||||
@@ -1480,6 +1516,11 @@ function insertBeforePromptStdin(args, extras) {
|
||||
|
||||
function buildNdcAgentMcpContext(payload, cwd) {
|
||||
const context = payload?.context && typeof payload.context === 'object' ? payload.context : {}
|
||||
const runProfile = payload?.runProfile && typeof payload.runProfile === 'object' ? payload.runProfile : {}
|
||||
const toolProfile = runProfile.toolProfile && typeof runProfile.toolProfile === 'object' ? runProfile.toolProfile : {}
|
||||
const assistantActions = toolProfile.assistantActions && typeof toolProfile.assistantActions === 'object'
|
||||
? toolProfile.assistantActions
|
||||
: {}
|
||||
const apiBaseUrl = deriveNdcAgentMcpApiBaseUrl(context)
|
||||
return {
|
||||
workflowId: String(context.workflowId || '').trim(),
|
||||
@@ -1492,6 +1533,14 @@ function buildNdcAgentMcpContext(payload, cwd) {
|
||||
schemaVersion: 'v2.3.2',
|
||||
n8nMcpRefPath: NDC_AGENT_MCP_REF_PATH,
|
||||
workspacePath: cwd,
|
||||
assistantActions: {
|
||||
actionIds: Array.isArray(assistantActions.actionIds)
|
||||
? assistantActions.actionIds.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: [],
|
||||
},
|
||||
assistantActionOwner: runProfile.owner && typeof runProfile.owner === 'object' ? runProfile.owner : {},
|
||||
assistantActionGatewayUrl: String(assistantActions.gatewayUrl || '').trim(),
|
||||
assistantActionGatewayToken: String(assistantActions.gatewayToken || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1502,6 +1551,7 @@ function deriveNdcAgentMcpApiBaseUrl(context) {
|
||||
if (!isHttpUrl(explicit)) return hubDerived || DEFAULT_NDC_AGENT_MCP_API_BASE
|
||||
if (hubDerived && isProtectedNodedcEngineUrl(explicit)) return hubDerived
|
||||
if (isLoopbackUrl(explicit) && hubDerived) return hubDerived
|
||||
if (isPrivateNetworkUrl(explicit) && hubDerived) return hubDerived
|
||||
if (!explicit.endsWith('/api/ndc-agent-mcp')) return `${explicit.replace(/\/+$/, '')}/api/ndc-agent-mcp`
|
||||
return explicit
|
||||
}
|
||||
@@ -1521,6 +1571,21 @@ function isLoopbackUrl(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivateNetworkUrl(value) {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
const host = url.hostname.toLowerCase()
|
||||
if (host === 'localhost' || host === '0.0.0.0' || host === '::1' || host.startsWith('127.')) return true
|
||||
if (host.startsWith('192.168.')) return true
|
||||
if (host.startsWith('10.')) return true
|
||||
if (host.startsWith('169.254.')) return true
|
||||
const match = host.match(/^172\.(\d+)\./)
|
||||
return Boolean(match && Number(match[1]) >= 16 && Number(match[1]) <= 31)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isHttpUrl(value) {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
@@ -1854,7 +1919,7 @@ async function prepareRunCodexHome({ payload = {}, cwd = CODEX_CWD, mcpContext =
|
||||
: NDC_AGENT_CODEX_HOME
|
||||
await fs.mkdir(codexHome, { recursive: true })
|
||||
await copyIfExists(path.join(CODEX_HOME, 'auth.json'), path.join(codexHome, 'auth.json'))
|
||||
const legacyOpsMcpConfig = needsNdcAgentCore && !hasDynamicMcp ? await readOptionalOpsMcpConfig() : ''
|
||||
const legacyOpsMcpConfig = ''
|
||||
const config = [
|
||||
runtimeCodexConfig(),
|
||||
needsNdcAgentCore ? ndcAgentMcpServerConfig(mcpContext, cwd) : '',
|
||||
@@ -1878,6 +1943,8 @@ async function codexInvocationForPayload(baseArgs, payload, cwd) {
|
||||
NDC_AGENT_MCP_ROOT: CODEX_CWD,
|
||||
NDC_AGENT_MCP_CONTEXT: JSON.stringify(mcpContext),
|
||||
NDC_AGENT_MCP_API_BASE_URL: mcpContext.ndcAgentMcpApiBaseUrl,
|
||||
NDC_AGENT_MCP_FETCH_TIMEOUT_MS: process.env.NDC_AGENT_MCP_FETCH_TIMEOUT_MS || '30000',
|
||||
AI_WORKSPACE_ASSISTANT_ACTION_FETCH_TIMEOUT_MS: process.env.AI_WORKSPACE_ASSISTANT_ACTION_FETCH_TIMEOUT_MS || '45000',
|
||||
AI_BRIDGE_PAIRING_CODE: PAIRING_CODE,
|
||||
} : {}),
|
||||
},
|
||||
@@ -2399,7 +2466,7 @@ function runCodex(prompt, args = CODEX_ARGS, onEvent = () => {}, timeoutMs = MES
|
||||
const timeout = setTimeout(() => {
|
||||
killed = true
|
||||
onEvent({ kind: 'timeout', message: 'Codex process timed out.' })
|
||||
child.kill('SIGTERM')
|
||||
terminateProcessTree(child, 'SIGTERM')
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
@@ -2431,7 +2498,7 @@ function runCodex(prompt, args = CODEX_ARGS, onEvent = () => {}, timeoutMs = MES
|
||||
plainStdout += text
|
||||
onEvent({ kind: 'stdout', stream: 'stdout', text })
|
||||
}
|
||||
if (Buffer.byteLength(stdout) > MAX_STDIO_BYTES) child.kill('SIGTERM')
|
||||
if (Buffer.byteLength(stdout) > MAX_STDIO_BYTES) terminateProcessTree(child, 'SIGTERM')
|
||||
})
|
||||
child.stderr.on('data', (chunk) => {
|
||||
const text = String(chunk)
|
||||
@@ -2441,7 +2508,7 @@ function runCodex(prompt, args = CODEX_ARGS, onEvent = () => {}, timeoutMs = MES
|
||||
}
|
||||
stderr += text
|
||||
onEvent({ kind: 'stderr', stream: 'stderr', text: sanitizeCodexErrorText(text) })
|
||||
if (Buffer.byteLength(stderr) > MAX_STDIO_BYTES) child.kill('SIGTERM')
|
||||
if (Buffer.byteLength(stderr) > MAX_STDIO_BYTES) terminateProcessTree(child, 'SIGTERM')
|
||||
})
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timeout)
|
||||
@@ -2927,6 +2994,8 @@ const ROOT = process.env.NDC_AGENT_MCP_ROOT || process.cwd()
|
||||
const DEFAULT_API_BASE = 'http://127.0.0.1:3001/api/ndc-agent-mcp'
|
||||
const DEFAULT_SCHEMA_VERSION = 'v2.3.2'
|
||||
const ENGINE_FETCH_TIMEOUT_MS = Number(process.env.NDC_AGENT_MCP_FETCH_TIMEOUT_MS || 8000)
|
||||
const DEFAULT_ASSISTANT_ACTION_GATEWAY_PATH = '/api/ai-workspace/assistant/v1/actions'
|
||||
const ASSISTANT_ACTION_FETCH_TIMEOUT_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_ACTION_FETCH_TIMEOUT_MS || ENGINE_FETCH_TIMEOUT_MS)
|
||||
const context = parseContext()
|
||||
const nodeCatalogCache = new Map()
|
||||
let transportMode = null
|
||||
@@ -3025,8 +3094,13 @@ function parseContext() {
|
||||
if (raw) {
|
||||
try { parsed = JSON.parse(raw) || {} } catch {}
|
||||
}
|
||||
const assistantActions = parseObject(
|
||||
parsed.assistantActions ||
|
||||
process.env.AI_WORKSPACE_ASSISTANT_ACTIONS ||
|
||||
{},
|
||||
)
|
||||
return {
|
||||
modeId: 'ndc-agent-core',
|
||||
modeId: cleanString(parsed.modeId || 'ndc-agent-core'),
|
||||
workflowId: cleanString(parsed.workflowId || process.env.NDC_AGENT_MCP_WORKFLOW_ID || ''),
|
||||
workflowTitle: cleanString(parsed.workflowTitle || process.env.NDC_AGENT_MCP_WORKFLOW_TITLE || ''),
|
||||
agentNodeId: cleanString(parsed.agentNodeId || process.env.NDC_AGENT_MCP_NODE_ID || ''),
|
||||
@@ -3036,6 +3110,20 @@ function parseContext() {
|
||||
apiBaseUrl: cleanApiBase(parsed.ndcAgentMcpApiBaseUrl || process.env.NDC_AGENT_MCP_API_BASE_URL || DEFAULT_API_BASE),
|
||||
schemaVersion: cleanString(parsed.schemaVersion || process.env.NDC_AGENT_MCP_SCHEMA_VERSION || DEFAULT_SCHEMA_VERSION),
|
||||
n8nMcpRefPath: cleanString(parsed.n8nMcpRefPath || process.env.NDC_AGENT_MCP_REF_PATH || path.resolve(ROOT, '..', 'tools', 'NDCMCP')),
|
||||
assistantActions,
|
||||
assistantActionOwner: parseObject(parsed.assistantActionOwner || {}),
|
||||
assistantActionGatewayUrl: cleanHttpUrl(
|
||||
parsed.assistantActionGatewayUrl ||
|
||||
process.env.AI_WORKSPACE_ASSISTANT_ACTION_GATEWAY_URL ||
|
||||
deriveAssistantActionGatewayUrlFromHub() ||
|
||||
'',
|
||||
),
|
||||
assistantActionGatewayToken: cleanString(
|
||||
parsed.assistantActionGatewayToken ||
|
||||
process.env.AI_WORKSPACE_ASSISTANT_ACTION_GATEWAY_TOKEN ||
|
||||
'',
|
||||
4000,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3043,6 +3131,32 @@ function cleanString(value, max = 1000) {
|
||||
return String(value || '').trim().slice(0, max)
|
||||
}
|
||||
|
||||
function parseObject(value, fallback = {}) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) return value
|
||||
const text = String(value || '').trim()
|
||||
if (!text) return fallback
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function cleanHttpUrl(value) {
|
||||
const text = cleanString(value, 2000).replace(/\/+$/, '')
|
||||
if (!text) return ''
|
||||
try {
|
||||
const url = new URL(text)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''
|
||||
url.username = ''
|
||||
url.password = ''
|
||||
return url.toString().replace(/\/+$/, '')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function cleanApiBase(value) {
|
||||
const raw = cleanString(value || DEFAULT_API_BASE)
|
||||
return raw.replace(/\/+$/, '') || DEFAULT_API_BASE
|
||||
@@ -3069,6 +3183,20 @@ function isLoopbackUrl(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivateNetworkUrl(value) {
|
||||
try {
|
||||
const host = new URL(value).hostname.toLowerCase()
|
||||
if (host === 'localhost' || host === '0.0.0.0' || host === '::1' || host.startsWith('127.')) return true
|
||||
if (host.startsWith('192.168.')) return true
|
||||
if (host.startsWith('10.')) return true
|
||||
if (host.startsWith('169.254.')) return true
|
||||
const match = host.match(/^172\.(\d+)\./)
|
||||
return Boolean(match && Number(match[1]) >= 16 && Number(match[1]) <= 31)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function hubOriginToApiBase(value) {
|
||||
try {
|
||||
const url = new URL(cleanString(value, 1000))
|
||||
@@ -3091,6 +3219,32 @@ function hubOriginToApiBase(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function hubOriginToAssistantActionGateway(value) {
|
||||
try {
|
||||
const url = new URL(cleanString(value, 1000))
|
||||
const host = url.hostname.toLowerCase()
|
||||
if (!host) return ''
|
||||
if (url.protocol === 'wss:') url.protocol = 'https:'
|
||||
else if (url.protocol === 'ws:') url.protocol = 'http:'
|
||||
else if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''
|
||||
url.pathname = ''
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
const origin = url.toString().replace(/\/+$/, '')
|
||||
return `${origin}${DEFAULT_ASSISTANT_ACTION_GATEWAY_PATH}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function deriveAssistantActionGatewayUrlFromHub() {
|
||||
const candidates = [
|
||||
hubOriginToAssistantActionGateway(process.env.AI_BRIDGE_HUB_URL),
|
||||
...splitList(process.env.AI_BRIDGE_HUB_URLS).map(hubOriginToAssistantActionGateway),
|
||||
].filter(Boolean)
|
||||
return uniqueStrings(candidates)[0] || ''
|
||||
}
|
||||
|
||||
function cleanPairingCode(value) {
|
||||
return cleanString(value, 100).toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 32)
|
||||
}
|
||||
@@ -3106,10 +3260,10 @@ function engineApiBaseCandidates() {
|
||||
...splitList(process.env.AI_BRIDGE_HUB_URLS).map(hubOriginToApiBase),
|
||||
]
|
||||
const envBase = cleanString(process.env.NDC_AGENT_MCP_API_BASE_URL, 1000).replace(/\/+$/, '')
|
||||
if (explicit && !isLoopbackUrl(explicit)) {
|
||||
if (explicit && !isPrivateNetworkUrl(explicit)) {
|
||||
return uniqueStrings([explicit, envBase])
|
||||
}
|
||||
if (envBase && !isLoopbackUrl(envBase)) {
|
||||
if (envBase && !isPrivateNetworkUrl(envBase)) {
|
||||
return uniqueStrings([envBase, explicit])
|
||||
}
|
||||
if (hubBases.some(Boolean)) {
|
||||
@@ -3191,6 +3345,88 @@ async function loadN8nMcpVersion() {
|
||||
return pkg?.version ? String(pkg.version) : '2.33.2'
|
||||
}
|
||||
|
||||
function assistantActionGatewayEndpoint() {
|
||||
const base = cleanHttpUrl(context.assistantActionGatewayUrl)
|
||||
if (!base) return ''
|
||||
if (base.endsWith(DEFAULT_ASSISTANT_ACTION_GATEWAY_PATH) || base.endsWith('/actions')) return base
|
||||
return `${base}${DEFAULT_ASSISTANT_ACTION_GATEWAY_PATH}`
|
||||
}
|
||||
|
||||
function assistantActionIds() {
|
||||
return Array.isArray(context.assistantActions?.actionIds)
|
||||
? context.assistantActions.actionIds.map((item) => cleanString(item, 200)).filter(Boolean)
|
||||
: []
|
||||
}
|
||||
|
||||
function assistantActionOwnerHeaders() {
|
||||
const owner = parseObject(context.assistantActionOwner, {})
|
||||
const headers = {}
|
||||
const put = (name, value) => {
|
||||
const text = cleanString(value, 1000)
|
||||
if (text) headers[name] = text
|
||||
}
|
||||
put('x-nodedc-user-id', owner.userId || owner.user_id)
|
||||
put('x-nodedc-user-email', owner.email)
|
||||
put('x-nodedc-user-role', owner.role)
|
||||
const groups = Array.isArray(owner.groups)
|
||||
? owner.groups.map((item) => cleanString(item, 120)).filter(Boolean).join(',')
|
||||
: owner.groups
|
||||
put('x-nodedc-user-groups', groups)
|
||||
return headers
|
||||
}
|
||||
|
||||
async function assistantActionFetch(payload = {}) {
|
||||
const endpoint = assistantActionGatewayEndpoint()
|
||||
const token = context.assistantActionGatewayToken
|
||||
if (!endpoint || !token) {
|
||||
return {
|
||||
ok: false,
|
||||
decision: 'assistant_action_gateway_unavailable',
|
||||
reason: 'Assistant action gateway URL/token is not configured for this run.',
|
||||
actionIds: assistantActionIds(),
|
||||
}
|
||||
}
|
||||
|
||||
let res = null
|
||||
let text = ''
|
||||
let json = null
|
||||
try {
|
||||
res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...assistantActionOwnerHeaders(),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(ASSISTANT_ACTION_FETCH_TIMEOUT_MS),
|
||||
})
|
||||
text = await res.text().catch(() => '')
|
||||
try { json = text ? JSON.parse(text) : null } catch {}
|
||||
} catch (error) {
|
||||
const out = new Error(`assistant_action_gateway_fetch_failed:${fetchFailureText(error)}`)
|
||||
out.payload = { decision: 'assistant_action_gateway_fetch_failed' }
|
||||
throw out
|
||||
}
|
||||
|
||||
if (!res.ok || json?.ok === false) {
|
||||
const out = new Error(json?.message || json?.error || `assistant_action_http_${res.status}`)
|
||||
out.status = res.status
|
||||
out.payload = json && typeof json === 'object'
|
||||
? json
|
||||
: { status: res.status, preview: cleanString(text, 400) }
|
||||
throw out
|
||||
}
|
||||
if (!json || typeof json !== 'object') {
|
||||
const out = new Error(`assistant_action_non_json_response:${res.status}`)
|
||||
out.status = 502
|
||||
out.payload = { status: res.status, preview: cleanString(text, 400) }
|
||||
throw out
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
function engineApiUrl(pathname) {
|
||||
return engineApiUrls(pathname)[0] || ''
|
||||
}
|
||||
@@ -3365,6 +3601,7 @@ async function handleGetContext() {
|
||||
...context,
|
||||
apiBaseCandidates: engineApiBaseCandidates(),
|
||||
n8nMcpVersion,
|
||||
assistantActionGatewayConfigured: Boolean(context.assistantActionGatewayUrl && context.assistantActionGatewayToken),
|
||||
contract: {
|
||||
sourceOfTruth: 'Engine second-level dc.subworkflow.json',
|
||||
writeApi: `${context.apiBaseUrl}/subworkflow/patch`,
|
||||
@@ -3375,6 +3612,40 @@ async function handleGetContext() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssistantActionCall(args = {}) {
|
||||
const phase = cleanString(args.phase || args.mode || 'preview', 40)
|
||||
if (!['preview', 'dry-run', 'execute'].includes(phase)) throw new Error('assistant_action_phase_invalid')
|
||||
|
||||
const input = parseObject(args.input || {}, {})
|
||||
const actionId = cleanString(args.actionId || input.actionId || '', 240)
|
||||
const intent = cleanString(args.intent || input.intent || '', 2000)
|
||||
if (actionId) input.actionId = actionId
|
||||
if (intent) input.intent = intent
|
||||
|
||||
const availableIds = assistantActionIds()
|
||||
if (actionId && availableIds.length && !availableIds.includes(actionId)) {
|
||||
throw new Error(`assistant_action_not_advertised:${actionId}`)
|
||||
}
|
||||
if (!actionId && !intent) {
|
||||
throw new Error('assistant_action_input_required')
|
||||
}
|
||||
|
||||
const confirmationToken = cleanString(
|
||||
args.confirmationToken ||
|
||||
args.confirmation?.token ||
|
||||
input.confirmationToken ||
|
||||
'',
|
||||
2000,
|
||||
)
|
||||
if (confirmationToken) input.confirmationToken = confirmationToken
|
||||
|
||||
return assistantActionFetch({
|
||||
phase,
|
||||
input,
|
||||
...(confirmationToken ? { confirmationToken } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
async function handleGetSubworkflow(args) {
|
||||
const target = targetFromArgs(args)
|
||||
const q = new URLSearchParams(target)
|
||||
@@ -3571,6 +3842,22 @@ const tools = [
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'assistant_action_call',
|
||||
description: 'Call the NODE.DC assistant action layer after interpreting user intent. Use execute for read actions after structured action selection; use preview before any privileged/write action and execute only after explicit confirmation.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
phase: { type: 'string', enum: ['preview', 'dry-run', 'execute'] },
|
||||
actionId: { type: 'string' },
|
||||
intent: { type: 'string' },
|
||||
input: { type: 'object', additionalProperties: true },
|
||||
confirmationToken: { type: 'string' },
|
||||
confirmation: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async function callTool(name, args) {
|
||||
@@ -3580,6 +3867,7 @@ async function callTool(name, args) {
|
||||
if (name === 'ndc_search_nodes') return handleSearchNodes(args)
|
||||
if (name === 'ndc_get_node_definition') return handleGetNodeDefinition(args)
|
||||
if (name === 'ndc_validate_subworkflow') return handleValidateSubworkflow(args)
|
||||
if (name === 'assistant_action_call') return handleAssistantActionCall(args)
|
||||
throw new Error(`unknown_tool:${name}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,17 @@ const MAX_EVENTS_PER_AGENT = 5000;
|
||||
const MAX_REQUEST_META_PER_AGENT = 1000;
|
||||
const HEARTBEAT_INTERVAL_MS = 30000;
|
||||
const AGENT_STALE_MS = 75000;
|
||||
const ASSISTANT_RELAY_POLL_TIMEOUT_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_POLL_TIMEOUT_MS || 25000);
|
||||
const ASSISTANT_RELAY_ACTION_TIMEOUT_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_ACTION_TIMEOUT_MS || 45000);
|
||||
const ASSISTANT_RELAY_MAX_PENDING = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_MAX_PENDING || 200);
|
||||
const ASSISTANT_RELAY_MAX_BATCH = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_MAX_BATCH || 10);
|
||||
|
||||
const config = readConfig();
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const agentsByCode = new Map();
|
||||
const assistantRelaysById = new Map();
|
||||
let eventSeq = 0;
|
||||
|
||||
app.disable("x-powered-by");
|
||||
@@ -25,6 +30,7 @@ app.get("/healthz", (_req, res) => {
|
||||
service: "nodedc-ai-workspace-hub",
|
||||
wsPath: config.wsPath,
|
||||
agentsOnline: Array.from(agentsByCode.values()).filter(isAgentOnline).length,
|
||||
assistantRelays: assistantRelaysById.size,
|
||||
internalApiConfigured: config.internalAccessTokens.length > 0,
|
||||
});
|
||||
});
|
||||
@@ -60,6 +66,12 @@ app.post("/api/ai-workspace/hub/v1/agents/:pairingCode/dispatch", requireInterna
|
||||
res.json({ ok: true, requestId: dispatch.requestId });
|
||||
});
|
||||
|
||||
app.post("/api/ai-workspace/assistant/v1/actions", requireInternalApi, asyncRoute(proxyAssistantActions));
|
||||
|
||||
app.post("/api/ai-workspace/hub/v1/assistant-relays/:relayId/actions", requireInternalApi, asyncRoute(callAssistantRelayAction));
|
||||
app.post("/api/ai-workspace/hub/v1/assistant-relays/:relayId/poll", requireInternalApi, asyncRoute(pollAssistantRelay));
|
||||
app.post("/api/ai-workspace/hub/v1/assistant-relays/:relayId/results/:callId", requireInternalApi, asyncRoute(completeAssistantRelayCall));
|
||||
|
||||
app.use("/api/ai-workspace/hub/v1/ndc-agent-mcp/:pairingCode", asyncRoute(proxyNdcAgentMcp));
|
||||
|
||||
app.use((error, _req, res, _next) => {
|
||||
@@ -256,6 +268,179 @@ async function proxyNdcAgentMcp(req, res) {
|
||||
res.send(text);
|
||||
}
|
||||
|
||||
async function proxyAssistantActions(req, res) {
|
||||
if (!config.assistantInternalUrl || !config.assistantInternalAccessToken) {
|
||||
res.status(503).json({ ok: false, error: "assistant_action_proxy_not_configured" });
|
||||
return;
|
||||
}
|
||||
const targetUrl = `${config.assistantInternalUrl.replace(/\/+$/, "")}/api/ai-workspace/assistant/v1/actions`;
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.assistantInternalAccessToken}`,
|
||||
...forwardOwnerHeaders(req),
|
||||
},
|
||||
body: JSON.stringify(req.body || {}),
|
||||
});
|
||||
const contentType = upstream.headers.get("content-type") || "application/json; charset=utf-8";
|
||||
const text = await upstream.text();
|
||||
res.status(upstream.status);
|
||||
res.setHeader("content-type", contentType);
|
||||
res.send(text);
|
||||
}
|
||||
|
||||
async function callAssistantRelayAction(req, res) {
|
||||
const relayId = cleanRelayId(req.params.relayId);
|
||||
if (!relayId) {
|
||||
res.status(400).json({ ok: false, error: "assistant_relay_id_required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const relay = getAssistantRelay(relayId);
|
||||
pruneAssistantRelay(relay);
|
||||
if (relay.calls.size >= ASSISTANT_RELAY_MAX_PENDING) {
|
||||
res.status(429).json({ ok: false, error: "assistant_relay_backpressure" });
|
||||
return;
|
||||
}
|
||||
|
||||
const callId = crypto.randomUUID();
|
||||
const timeoutMs = sanitizeTimeoutMs(req.body?.timeoutMs || req.query?.timeoutMs || ASSISTANT_RELAY_ACTION_TIMEOUT_MS, ASSISTANT_RELAY_ACTION_TIMEOUT_MS);
|
||||
const createdAt = new Date().toISOString();
|
||||
const call = {
|
||||
callId,
|
||||
relayId,
|
||||
createdAt,
|
||||
payload: req.body || {},
|
||||
headers: forwardOwnerHeaders(req),
|
||||
};
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
relay.calls.delete(callId);
|
||||
relay.pending = relay.pending.filter((item) => item.callId !== callId);
|
||||
const error = new Error("assistant_relay_timeout");
|
||||
error.status = 504;
|
||||
reject(error);
|
||||
}, timeoutMs);
|
||||
timeout.unref?.();
|
||||
relay.calls.set(callId, { resolve, reject, timeout, createdAt: Date.now() });
|
||||
relay.pending.push(call);
|
||||
notifyAssistantRelayWaiters(relay);
|
||||
});
|
||||
|
||||
res.status(result.status).json(result.body);
|
||||
}
|
||||
|
||||
async function pollAssistantRelay(req, res) {
|
||||
const relayId = cleanRelayId(req.params.relayId);
|
||||
if (!relayId) {
|
||||
res.status(400).json({ ok: false, error: "assistant_relay_id_required" });
|
||||
return;
|
||||
}
|
||||
const relay = getAssistantRelay(relayId);
|
||||
relay.lastSeenAt = new Date().toISOString();
|
||||
const limit = sanitizeInteger(req.body?.limit, ASSISTANT_RELAY_MAX_BATCH, 1, ASSISTANT_RELAY_MAX_BATCH);
|
||||
const timeoutMs = sanitizeTimeoutMs(req.body?.timeoutMs || req.query?.timeoutMs || ASSISTANT_RELAY_POLL_TIMEOUT_MS, ASSISTANT_RELAY_POLL_TIMEOUT_MS);
|
||||
const calls = await waitForAssistantRelayCalls(relay, limit, timeoutMs);
|
||||
res.json({ ok: true, relayId, calls });
|
||||
}
|
||||
|
||||
async function completeAssistantRelayCall(req, res) {
|
||||
const relayId = cleanRelayId(req.params.relayId);
|
||||
const callId = cleanString(req.params.callId, 120);
|
||||
const relay = assistantRelaysById.get(relayId);
|
||||
const pending = relay?.calls.get(callId);
|
||||
if (!relay || !pending) {
|
||||
res.status(404).json({ ok: false, error: "assistant_relay_call_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
relay.calls.delete(callId);
|
||||
clearTimeout(pending.timeout);
|
||||
pending.resolve({
|
||||
status: sanitizeHttpStatus(req.body?.status),
|
||||
body: req.body?.body === undefined ? { ok: true } : req.body.body,
|
||||
});
|
||||
res.json({ ok: true, relayId, callId });
|
||||
}
|
||||
|
||||
function getAssistantRelay(relayId) {
|
||||
const cleanId = cleanRelayId(relayId);
|
||||
let relay = assistantRelaysById.get(cleanId);
|
||||
if (!relay) {
|
||||
relay = {
|
||||
relayId: cleanId,
|
||||
pending: [],
|
||||
waiters: [],
|
||||
calls: new Map(),
|
||||
createdAt: new Date().toISOString(),
|
||||
lastSeenAt: "",
|
||||
};
|
||||
assistantRelaysById.set(cleanId, relay);
|
||||
}
|
||||
return relay;
|
||||
}
|
||||
|
||||
function waitForAssistantRelayCalls(relay, limit, timeoutMs) {
|
||||
const ready = takeAssistantRelayCalls(relay, limit);
|
||||
if (ready.length) return Promise.resolve(ready);
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
relay.waiters = relay.waiters.filter((waiter) => waiter.resolve !== resolve);
|
||||
resolve([]);
|
||||
}, timeoutMs);
|
||||
timeout.unref?.();
|
||||
relay.waiters.push({ resolve, timeout, limit });
|
||||
});
|
||||
}
|
||||
|
||||
function notifyAssistantRelayWaiters(relay) {
|
||||
while (relay.waiters.length && relay.pending.length) {
|
||||
const waiter = relay.waiters.shift();
|
||||
clearTimeout(waiter.timeout);
|
||||
waiter.resolve(takeAssistantRelayCalls(relay, waiter.limit));
|
||||
}
|
||||
}
|
||||
|
||||
function takeAssistantRelayCalls(relay, limit) {
|
||||
const calls = [];
|
||||
while (relay.pending.length && calls.length < limit) {
|
||||
const call = relay.pending.shift();
|
||||
if (relay.calls.has(call.callId)) calls.push(call);
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
function pruneAssistantRelay(relay) {
|
||||
relay.pending = relay.pending.filter((call) => relay.calls.has(call.callId));
|
||||
}
|
||||
|
||||
function cleanRelayId(value) {
|
||||
return String(value || "").trim().replace(/[^A-Za-z0-9_.:-]/g, "").slice(0, 120);
|
||||
}
|
||||
|
||||
function sanitizeHttpStatus(value) {
|
||||
const status = Number(value || 200);
|
||||
return Number.isInteger(status) && status >= 100 && status <= 599 ? status : 200;
|
||||
}
|
||||
|
||||
function forwardOwnerHeaders(req) {
|
||||
const headers = {};
|
||||
for (const name of [
|
||||
"x-nodedc-user-id",
|
||||
"x-nodedc-user-email",
|
||||
"x-nodedc-user-role",
|
||||
"x-nodedc-user-groups",
|
||||
]) {
|
||||
const value = req.headers[name];
|
||||
if (typeof value === "string" && value.trim()) headers[name] = value.trim();
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function startAgentRequest(pairingCodeRaw, command, payload = {}, timeoutMs = 30000, options = {}) {
|
||||
const pairingCode = cleanPairingCode(pairingCodeRaw);
|
||||
const agent = agentsByCode.get(pairingCode);
|
||||
@@ -432,6 +617,12 @@ function sanitizeTimeoutMs(value, fallback) {
|
||||
return Math.max(1000, Math.min(12 * 60 * 60 * 1000, numeric));
|
||||
}
|
||||
|
||||
function sanitizeInteger(value, fallback, min, max) {
|
||||
const numeric = Number(value || fallback);
|
||||
if (!Number.isFinite(numeric)) return fallback;
|
||||
return Math.min(Math.max(Math.trunc(numeric), min), max);
|
||||
}
|
||||
|
||||
function elapsedLabel(startedAt) {
|
||||
const elapsed = Math.max(0, Date.now() - Number(startedAt || Date.now()));
|
||||
if (elapsed < 1000) return `${elapsed}ms`;
|
||||
@@ -473,6 +664,14 @@ function readConfig() {
|
||||
internalAccessTokens,
|
||||
engineInternalUrl: cleanString(process.env.NODEDC_ENGINE_INTERNAL_URL, 1000).replace(/\/+$/, ""),
|
||||
engineInternalAccessToken: cleanString(process.env.NODEDC_INTERNAL_ACCESS_TOKEN, 1000),
|
||||
assistantInternalUrl: cleanString(
|
||||
process.env.NODEDC_AI_WORKSPACE_ASSISTANT_URL ||
|
||||
process.env.NDC_AI_WORKSPACE_ASSISTANT_INTERNAL_URL ||
|
||||
process.env.AI_WORKSPACE_ASSISTANT_INTERNAL_URL ||
|
||||
"http://ai-workspace-assistant:18082",
|
||||
1000,
|
||||
).replace(/\/+$/, ""),
|
||||
assistantInternalAccessToken: cleanString(process.env.NODEDC_INTERNAL_ACCESS_TOKEN, 1000),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user