feat: sync platform ai workspace services

This commit is contained in:
Codex 2026-06-13 11:34:40 +03:00
parent 5e0204b049
commit 385e2732e8
8 changed files with 2039 additions and 59 deletions

View File

@ -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_TOKEN=change-me-generate-with-infra-scripts-init-dev-env
AI_WORKSPACE_HUB_HOST_BIND=127.0.0.1:18081 AI_WORKSPACE_HUB_HOST_BIND=127.0.0.1:18081
AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub
AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru
AI_WORKSPACE_HUB_FALLBACK_URLS= AI_WORKSPACE_HUB_FALLBACK_URLS=

View File

@ -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} 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_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_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:-} AI_WORKSPACE_HUB_FALLBACK_URLS: ${AI_WORKSPACE_HUB_FALLBACK_URLS:-}
expose: expose:
- "18082" - "18082"

View File

@ -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_TOKEN=replace-with-random-synology-secret
AI_WORKSPACE_HUB_HOST_BIND=0.0.0.0:18081 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_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= AI_WORKSPACE_HUB_FALLBACK_URLS=

View File

@ -41,10 +41,11 @@ Installer generation must always point remote Codex workers to the deployed Hub:
```env ```env
AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub
AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru
AI_WORKSPACE_HUB_FALLBACK_URLS= 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.
## Локальные домены для первичной проверки ## Локальные домены для первичной проверки

View File

@ -139,6 +139,8 @@ services:
AI_WORKSPACE_ASSISTANT_TOKEN: ${AI_WORKSPACE_ASSISTANT_TOKEN:?ai workspace assistant token required} AI_WORKSPACE_ASSISTANT_TOKEN: ${AI_WORKSPACE_ASSISTANT_TOKEN:?ai workspace assistant token required}
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-} 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_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:-} AI_WORKSPACE_HUB_FALLBACK_URLS: ${AI_WORKSPACE_HUB_FALLBACK_URLS:-}
expose: expose:
- "18082" - "18082"

File diff suppressed because it is too large Load Diff

View File

@ -11,10 +11,14 @@ param(
[string]$HubFallbackUrls = "", [string]$HubFallbackUrls = "",
[string]$PairingCode = "", [string]$PairingCode = "",
[string]$MachineName = "", [string]$MachineName = "",
[string]$OpsMcpUrl = "",
[string]$OpsMcpToken = "",
[string]$OpsMcpServerName = "nodedc_tasker",
[string]$AppMcpServersJson = "",
[switch]$NoAutostart, [switch]$NoAutostart,
[switch]$NoFirewall, [switch]$NoFirewall,
[switch]$NoCodexLogin, [switch]$NoCodexLogin,
[switch]$EnableWatchdog, [switch]$EnableWatchdog = $true,
[switch]$Confirmed [switch]$Confirmed
) )
@ -215,6 +219,10 @@ function Request-Elevation {
if ($HubFallbackUrls) { $args += @('-HubFallbackUrls', "`"$HubFallbackUrls`"") } if ($HubFallbackUrls) { $args += @('-HubFallbackUrls', "`"$HubFallbackUrls`"") }
if ($PairingCode) { $args += @('-PairingCode', "`"$PairingCode`"") } if ($PairingCode) { $args += @('-PairingCode', "`"$PairingCode`"") }
if ($MachineName) { $args += @('-MachineName', "`"$MachineName`"") } 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 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 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 { function Get-WorkspacePath {
$workspacePath = Get-DefaultWorkspaceCandidate $workspacePath = Get-DefaultWorkspaceCandidate
Write-Ok "Codex workspace: $workspacePath" Write-Ok "Codex workspace: $workspacePath"
@ -863,37 +1050,97 @@ function buildPrompt(payload) {
const userMessage = String(payload?.message || '').trim() const userMessage = String(payload?.message || '').trim()
const history = payload?.resume ? [] : normalizeHistory(payload?.history || []) const history = payload?.resume ? [] : normalizeHistory(payload?.history || [])
const isNdcAgentCore = String(context.modeId || '').trim() === 'ndc-agent-core' 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 ndcAgentMcpApiBaseUrl = deriveNdcAgentMcpApiBaseUrl(context)
const lines = [ const lines = [
'You are connected to NODE.DC AI Workspace through AI Workspace Bridge.', 'You are connected to NODE.DC AI Workspace through AI Workspace Bridge.',
'', '',
'Context:', 'Context:',
`- active surface: ${context.surface || 'unknown'}`,
`- source surface: ${context.sourceSurface || context.surface || 'unknown'}`,
`- mode: ${context.modeTitle || context.modeId || 'unknown'}`, `- mode: ${context.modeTitle || context.modeId || 'unknown'}`,
`- workflow: ${context.workflowTitle || context.workflowId || 'unknown'}`, `- workflow: ${context.workflowTitle || context.workflowId || 'unknown'}`,
`- agent node: ${context.agentNodeTitle || context.agentNodeId || 'unknown'}`, `- agent node: ${context.agentNodeTitle || context.agentNodeId || 'unknown'}`,
`- role: ${context.roleTitle || context.roleId || 'unknown'}`, `- role: ${context.roleTitle || context.roleId || 'unknown'}`,
`- workspace: ${context.workspacePath || payload?.workspacePath || CODEX_CWD}`, `- 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', '- 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', '- 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', '- 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', '- keep public progress/reasoning summaries user-facing; do not include raw command lines, JSON, hidden chain-of-thought, or bridge transport details',
...isNdcAgentCore ? [ ...isNdcAgentCore ? [
'', '',
'NDC Agent Core mode contract:', 'NDC Agent Core mode contract:',
'- Treat the selected agent node as the only writable target.', ...!contextReady ? [
'- Do not write directly to the NDC core runtime and do not modify deployed runtime workflows by hand.', '- 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.',
'- The Engine second-level workflow file is the source of truth for edits.', '- Do not claim that an Engine agent node is selected when it is missing.',
`- Engine API base for this target: ${ndcAgentMcpApiBaseUrl}`, '- Do not create, edit, patch, deploy, or write Engine workflow graph changes until the missing Engine agent node is selected.',
`- Selected workflowId: ${context.workflowId || 'unknown'}`, '- 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.',
`- Selected agentNodeId: ${context.agentNodeId || 'unknown'}`, ] : [
'- Read the second-level graph with GET /subworkflow?workflowId=<workflowId>&nodeId=<agentNodeId>.', '- Treat the selected Engine agent node as the only writable Engine workflow target.',
'- Apply graph edits with POST /subworkflow/patch using JSON: { workflowId, nodeId, intent, operations }.', '- Do not write directly to the NDC core runtime and do not modify deployed runtime workflows by hand.',
'- 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.', '- The Engine second-level workflow file is the source of truth for edits.',
'- Do not use direct n8n/NDC core MCP servers, local workflow files, or shell probes for graph edits in this mode.', `- Engine API base for this target: ${ndcAgentMcpApiBaseUrl}`,
'- 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.', `- Selected workflowId: ${context.workflowId || 'unknown'}`,
'- Supported operations: addNode, upsertNode, updateNode, removeNode, moveNode, addConnection, removeConnection.', `- Selected agentNodeId: ${context.agentNodeId || 'unknown'}`,
'- Prefer small patches and preserve existing node ids, node names, positions, parameters, and connections unless the user asks to change them.', '- Read the second-level graph with GET /subworkflow?workflowId=<workflowId>&nodeId=<agentNodeId>.',
'- 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.', '- 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 ? [ ...history.length ? [
@ -1354,6 +1601,99 @@ function stripTopLevelTomlKeys(raw, keyNames) {
return result.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() 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) { async function copyIfExists(from, to) {
try { try {
await fs.copyFile(from, to) await fs.copyFile(from, to)
@ -1380,6 +1720,7 @@ function ndcAgentMcpEnvConfig(mcpContext, cwd) {
async function prepareNdcAgentCodexHome(mcpContext = {}, cwd = CODEX_CWD) { async function prepareNdcAgentCodexHome(mcpContext = {}, cwd = CODEX_CWD) {
await fs.mkdir(NDC_AGENT_CODEX_HOME, { recursive: true }) 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')) await copyIfExists(path.join(CODEX_HOME, 'auth.json'), path.join(NDC_AGENT_CODEX_HOME, 'auth.json'))
const opsMcpConfig = await readOptionalOpsMcpConfig()
const runtimeConfig = [ const runtimeConfig = [
'approval_policy = "never"', 'approval_policy = "never"',
'sandbox_mode = "danger-full-access"', 'sandbox_mode = "danger-full-access"',
@ -1391,7 +1732,12 @@ async function prepareNdcAgentCodexHome(mcpContext = {}, cwd = CODEX_CWD) {
'startup_timeout_sec = 20', 'startup_timeout_sec = 20',
'tool_timeout_sec = 120', 'tool_timeout_sec = 120',
].join('\n') ].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 return NDC_AGENT_CODEX_HOME
} }
@ -1600,13 +1946,14 @@ function createActiveMirror(command, payload = {}, meta = {}) {
const context = payload?.context && typeof payload.context === 'object' ? payload.context : {} const context = payload?.context && typeof payload.context === 'object' ? payload.context : {}
if (!ACTIVE_MIRROR_ENABLED || command !== 'message' || context.remoteControlMode !== 'active') return null if (!ACTIVE_MIRROR_ENABLED || command !== 'message' || context.remoteControlMode !== 'active') return null
const cwd = String(meta.cwd || CODEX_CWD) const cwd = String(meta.cwd || CODEX_CWD)
const displayMessage = String(payload.displayMessage || payload.publicUserMessage || payload.message || '')
const { mirrorDir, currentFile, eventsFile } = activeMirrorPathsForCwd(cwd) const { mirrorDir, currentFile, eventsFile } = activeMirrorPathsForCwd(cwd)
const startedAt = new Date().toISOString() const startedAt = new Date().toISOString()
const state = { const state = {
requestId: String(meta.requestId || payload.threadId || `local-${Date.now()}`), requestId: String(meta.requestId || payload.threadId || `local-${Date.now()}`),
threadId: String(payload.threadId || ''), threadId: String(payload.threadId || ''),
threadTitle: String(payload.threadTitle || ''), threadTitle: String(payload.threadTitle || ''),
userMessage: String(payload.message || ''), userMessage: displayMessage,
modeId: String(context.modeId || ''), modeId: String(context.modeId || ''),
modeTitle: String(context.modeTitle || ''), modeTitle: String(context.modeTitle || ''),
machineName: MACHINE_NAME, machineName: MACHINE_NAME,
@ -2620,10 +2967,16 @@ function engineApiBaseCandidates() {
...splitList(process.env.AI_BRIDGE_HUB_URLS).map(hubOriginToApiBase), ...splitList(process.env.AI_BRIDGE_HUB_URLS).map(hubOriginToApiBase),
] ]
const envBase = cleanString(process.env.NDC_AGENT_MCP_API_BASE_URL, 1000).replace(/\/+$/, '') const envBase = cleanString(process.env.NDC_AGENT_MCP_API_BASE_URL, 1000).replace(/\/+$/, '')
const ordered = isLoopbackUrl(explicit) && hubBases.some(Boolean) if (explicit && !isLoopbackUrl(explicit)) {
? [...hubBases, envBase, explicit, DEFAULT_API_BASE] return uniqueStrings([explicit, envBase])
: [explicit, envBase, ...hubBases, DEFAULT_API_BASE] }
return uniqueStrings(ordered) 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) { function engineApiUrls(pathname) {
@ -2886,20 +3239,11 @@ async function handleGetContext() {
async function handleGetSubworkflow(args) { async function handleGetSubworkflow(args) {
const target = targetFromArgs(args) const target = targetFromArgs(args)
const q = new URLSearchParams(target) const q = new URLSearchParams(target)
try { const result = await engineFetch(`/subworkflow?${q.toString()}`)
const result = await engineFetch(`/subworkflow?${q.toString()}`) return {
return { ok: true,
ok: true, ...(result && typeof result === 'object' ? result : {}),
...(result && typeof result === 'object' ? result : {}), graph: normalizeGraphResult(result?.graph, target.workflowId, target.nodeId),
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,
}
} }
} }
@ -3767,6 +4111,8 @@ function Main {
Ensure-Codex Ensure-Codex
Ensure-CodexLogin Ensure-CodexLogin
Repair-CodexMcpConfig Repair-CodexMcpConfig
Ensure-AppMcpConfigs
if (-not $AppMcpServersJson) { Ensure-OpsMcpConfig }
Stop-BridgeNow Stop-BridgeNow
$script:Port = Resolve-BridgePort $script:Port = Resolve-BridgePort
$files = Write-WorkerFiles -Root $InstallRoot -WorkspacePath $workspacePath $files = Write-WorkerFiles -Root $InstallRoot -WorkspacePath $workspacePath

View File

@ -307,11 +307,12 @@ function readAgentEvents(pairingCodeRaw, options = {}) {
function requestMetaFromPayload(command, payload = {}) { function requestMetaFromPayload(command, payload = {}) {
const context = payload?.context && typeof payload.context === "object" ? payload.context : {}; const context = payload?.context && typeof payload.context === "object" ? payload.context : {};
const publicUserMessage = payload?.displayMessage || payload?.publicUserMessage || payload?.message;
return { return {
command: cleanString(command, 80), command: cleanString(command, 80),
threadId: cleanString(payload?.threadId, 160), threadId: cleanString(payload?.threadId, 160),
threadTitle: cleanString(payload?.threadTitle, 240), threadTitle: cleanString(payload?.threadTitle, 240),
userMessage: cleanString(payload?.message, 12000), userMessage: cleanString(publicUserMessage, 12000),
remoteControlMode: cleanString(context.remoteControlMode, 40), remoteControlMode: cleanString(context.remoteControlMode, 40),
}; };
} }