NODEDC_PLATFORM/services/ai-workspace-assistant/src/templates/install-windows.ps1

4294 lines
150 KiB
PowerShell

# NDC AI Workspace Bridge installer for Windows.
# Run from an elevated PowerShell session:
# powershell -NoProfile -ExecutionPolicy Bypass -File .\install-windows.ps1
[CmdletBinding()]
param(
[int]$Port = 8787,
[string]$InstallRoot = "$env:LOCALAPPDATA\NDC\AIWorkspaceBridge",
[string]$Workspace = "",
[string]$HubUrl = "",
[string]$HubFallbackUrls = "",
[string]$PairingCode = "",
[string]$MachineName = "",
[string]$OpsMcpUrl = "",
[string]$OpsMcpToken = "",
[string]$OpsMcpServerName = "nodedc_tasker",
[string]$AppMcpServersJson = "",
[switch]$NoAutostart,
[switch]$NoFirewall,
[switch]$NoCodexLogin,
[switch]$EnableWatchdog = $true,
[switch]$Confirmed
)
$ErrorActionPreference = 'Stop'
$TaskName = 'NDC AI Workspace Bridge'
$HealthPath = '/api/ai-workspace/bridge/v1/health'
$ConversationPath = '/api/ai-workspace/bridge/v1/conversations'
function Get-ResultPath {
if ($PSScriptRoot -and (Test-Path $PSScriptRoot)) {
return (Join-Path $PSScriptRoot 'install-result.txt')
}
return (Join-Path $InstallRoot 'install-result.txt')
}
function Write-InstallResult {
param([string]$Text)
$resultPath = Get-ResultPath
try {
$resultDir = Split-Path -Parent $resultPath
if ($resultDir) { New-Item -ItemType Directory -Force -Path $resultDir | Out-Null }
Set-Content -Path $resultPath -Value $Text -Encoding UTF8
Write-Host ""
Write-Host "Result file: $resultPath" -ForegroundColor Green
} catch {
Write-Warn "Could not write result file: $($_.Exception.Message)"
}
}
function Write-Step {
param([string]$Text)
Write-Host ""
Write-Host "== $Text ==" -ForegroundColor Cyan
}
function Write-Ok {
param([string]$Text)
Write-Host "[ok] $Text" -ForegroundColor Green
}
function Write-Warn {
param([string]$Text)
Write-Host "[warn] $Text" -ForegroundColor Yellow
}
function Test-Admin {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Refresh-Path {
$machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine')
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
$env:Path = "$machinePath;$userPath"
}
function Add-UserPath {
param([string]$PathToAdd)
if (-not $PathToAdd -or -not (Test-Path $PathToAdd)) { return }
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
$parts = @()
if ($userPath) { $parts = $userPath -split ';' | Where-Object { $_ } }
if ($parts -notcontains $PathToAdd) {
$nextPath = ($parts + $PathToAdd) -join ';'
[Environment]::SetEnvironmentVariable('Path', $nextPath, 'User')
}
Refresh-Path
}
function Resolve-CommandSource {
param([string]$Name)
$command = Get-Command $Name -ErrorAction SilentlyContinue
if ($command) { return $command.Source }
return $null
}
function Get-NpmPath {
$command = Get-Command 'npm.cmd' -ErrorAction SilentlyContinue
if (-not $command) { $command = Get-Command 'npm' -ErrorAction SilentlyContinue }
if ($command) { return $command.Source }
return $null
}
function Invoke-NpmInstallCodex {
$npmPath = Get-NpmPath
if (-not $npmPath) { throw 'npm was not found.' }
& $npmPath 'install' '-g' '@openai/codex' '--no-audit' '--no-fund'
if ($LASTEXITCODE -ne 0) {
throw "npm install -g @openai/codex exited with code $LASTEXITCODE"
}
}
function Get-NpmGlobalPrefix {
$npmPath = Get-NpmPath
if (-not $npmPath) { throw 'npm was not found.' }
$prefix = (& $npmPath 'prefix' '-g') -join ''
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($prefix)) {
throw "npm prefix -g exited with code $LASTEXITCODE"
}
return $prefix.Trim()
}
function Get-NpmVersion {
$npmPath = Get-NpmPath
if (-not $npmPath) { return $null }
return ((& $npmPath '--version') -join '').Trim()
}
function ConvertTo-PsSingleQuoted {
param([string]$Value)
return "'" + ($Value -replace "'", "''") + "'"
}
function Get-HubUrlCandidates {
$items = @()
if ($HubUrl) { $items += $HubUrl }
if ($HubFallbackUrls) {
$items += ($HubFallbackUrls -split '[,;\r\n]+' | Where-Object { $_ })
}
$result = @()
foreach ($item in $items) {
$clean = ([string]$item).Trim()
if (-not $clean) { continue }
if ($result -notcontains $clean) { $result += $clean }
}
return @($result)
}
function Test-BridgeWorkspaceCandidate {
param([string]$PathValue)
if (-not $PathValue) { return $false }
try {
$leaf = Split-Path -Leaf $PathValue
if ($leaf -and $leaf.Equals('ai-workspace-bridge', [StringComparison]::OrdinalIgnoreCase)) { return $true }
if ($InstallRoot -and $PathValue.StartsWith($InstallRoot, [StringComparison]::OrdinalIgnoreCase)) { return $true }
} catch {}
return $false
}
function Resolve-WorkspaceCandidate {
param([string]$PathValue)
if (-not $PathValue) { return $null }
$candidate = $PathValue.Trim('"')
if (-not (Test-Path $candidate)) { return $candidate }
if (-not (Test-BridgeWorkspaceCandidate $candidate)) { return $candidate }
try {
$parent = Split-Path -Parent $candidate
if ($parent -and (Test-Path $parent)) { return $parent }
} catch {}
return $candidate
}
function Get-DefaultWorkspaceCandidate {
if ($Workspace) { return $Workspace.Trim('"') }
try {
$location = Get-Location
if ($location.Provider.Name -eq 'FileSystem' -and (Test-Path $location.ProviderPath)) {
$windowsRoot = [Environment]::GetFolderPath('Windows')
if (-not $windowsRoot -or -not $location.ProviderPath.StartsWith($windowsRoot, [StringComparison]::OrdinalIgnoreCase)) {
return (Resolve-WorkspaceCandidate $location.ProviderPath)
}
}
} catch {}
if ($PSCommandPath) {
$scriptDir = Split-Path -Parent $PSCommandPath
if ($scriptDir -and (Test-Path $scriptDir)) { return (Resolve-WorkspaceCandidate $scriptDir) }
}
return $env:USERPROFILE
}
function Request-Elevation {
if (Test-Admin) { return }
if (-not $PSCommandPath) {
throw 'This installer needs Administrator rights when pasted directly. Save it as install-windows.ps1 and run it again.'
}
Write-Warn 'Administrator rights are needed for Node install and Windows Firewall rule.'
$workspaceForElevated = Get-DefaultWorkspaceCandidate
$args = @(
'-NoProfile',
'-NoExit',
'-ExecutionPolicy', 'Bypass',
'-File', "`"$PSCommandPath`"",
'-Port', $Port,
'-InstallRoot', "`"$InstallRoot`"",
'-Workspace', "`"$workspaceForElevated`"",
'-Confirmed'
)
if ($NoAutostart) { $args += '-NoAutostart' }
if ($NoFirewall) { $args += '-NoFirewall' }
if ($NoCodexLogin) { $args += '-NoCodexLogin' }
if ($EnableWatchdog) { $args += '-EnableWatchdog' }
if ($HubUrl) { $args += @('-HubUrl', "`"$HubUrl`"") }
if ($HubFallbackUrls) { $args += @('-HubFallbackUrls', "`"$HubFallbackUrls`"") }
if ($PairingCode) { $args += @('-PairingCode', "`"$PairingCode`"") }
if ($MachineName) { $args += @('-MachineName', "`"$MachineName`"") }
if ($OpsMcpUrl) { $args += @('-OpsMcpUrl', "`"$OpsMcpUrl`"") }
if ($OpsMcpToken) { $args += @('-OpsMcpToken', "`"$OpsMcpToken`"") }
if ($OpsMcpServerName) { $args += @('-OpsMcpServerName', "`"$OpsMcpServerName`"") }
if ($AppMcpServersJson) { $args += @('-AppMcpServersJson', (ConvertTo-PsSingleQuoted $AppMcpServersJson)) }
Write-Host 'Opening an Administrator PowerShell window. Approve the Windows UAC prompt and continue there.' -ForegroundColor Yellow
Start-Process -FilePath 'powershell.exe' -ArgumentList ($args -join ' ') -Verb RunAs -Wait
Write-Host 'Administrator installer window finished. You can close this window.' -ForegroundColor Green
exit 0
}
function Install-NodeWithWinget {
$winget = Resolve-CommandSource 'winget'
if (-not $winget) { return $false }
Write-Step 'Installing Node.js LTS with winget'
& $winget install --id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements
if ($LASTEXITCODE -eq 0) {
Refresh-Path
return $true
}
Write-Warn 'winget did not finish successfully; falling back to direct Node.js MSI.'
return $false
}
function Install-NodeWithMsi {
Write-Step 'Installing Node.js LTS from nodejs.org'
$arch = 'win-x64-msi'
if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64') {
$arch = 'win-arm64-msi'
} elseif ($env:PROCESSOR_ARCHITECTURE -eq 'x86' -and -not $env:PROCESSOR_ARCHITEW6432) {
$arch = 'win-x86-msi'
}
$index = Invoke-RestMethod -Uri 'https://nodejs.org/dist/index.json'
$release = $index | Where-Object { $_.lts -ne $false -and $_.files -contains $arch } | Select-Object -First 1
if (-not $release) { throw "Could not find a Node.js LTS MSI for $arch" }
$tempDir = Join-Path $env:TEMP 'ndc-ai-workspace-bridge'
New-Item -ItemType Directory -Force -Path $tempDir | Out-Null
$msi = Join-Path $tempDir "node-$($release.version)-$arch.msi"
$url = "https://nodejs.org/dist/$($release.version)/node-$($release.version)-$arch.msi"
Invoke-WebRequest -Uri $url -OutFile $msi
$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList "/i `"$msi`" /qn /norestart" -Wait -PassThru
if ($process.ExitCode -ne 0) {
throw "Node.js MSI failed with code $($process.ExitCode)"
}
Refresh-Path
}
function Ensure-Node {
Write-Step 'Checking Node.js'
Refresh-Path
if (Resolve-CommandSource 'node') {
Write-Ok "Node found: $(& node --version)"
} else {
if (-not (Install-NodeWithWinget)) {
Install-NodeWithMsi
}
}
Refresh-Path
if (-not (Resolve-CommandSource 'node')) { throw 'Node.js was not found after installation.' }
if (-not (Get-NpmPath)) { throw 'npm was not found after Node.js installation.' }
Write-Ok "npm found: $(Get-NpmVersion)"
}
function Ensure-Codex {
Write-Step 'Checking Codex CLI'
Refresh-Path
if (-not (Resolve-CommandSource 'codex')) {
Invoke-NpmInstallCodex
Refresh-Path
}
$npmPrefix = Get-NpmGlobalPrefix
Add-UserPath $npmPrefix
Refresh-Path
if (-not (Resolve-CommandSource 'codex')) {
$codexCmd = Join-Path $npmPrefix 'codex.cmd'
if (Test-Path $codexCmd) {
Add-UserPath $npmPrefix
}
}
if (-not (Resolve-CommandSource 'codex')) {
throw 'Codex CLI was installed, but codex is not available in PATH.'
}
try {
$version = (& codex --version) -join ' '
Write-Ok "Codex found: $version"
} catch {
Write-Ok 'Codex found.'
}
}
function Ensure-CodexLogin {
if ($NoCodexLogin) {
Write-Warn 'Codex login skipped by flag.'
return
}
$authFile = Join-Path $env:USERPROFILE '.codex\auth.json'
if (Test-Path $authFile) {
Write-Ok 'Codex auth file found.'
return
}
Write-Step 'Codex login'
Write-Host 'A browser login flow may open. Finish it, then return to this window.'
$loginOk = $false
try {
& codex login
if ($LASTEXITCODE -eq 0) { $loginOk = $true }
} catch {
$loginOk = $false
}
if (-not $loginOk) {
try {
& codex --login
if ($LASTEXITCODE -eq 0) { $loginOk = $true }
} catch {
$loginOk = $false
}
}
if (-not $loginOk -and -not (Test-Path $authFile)) {
Write-Warn 'Codex login did not complete. The bridge will start, but messages will fail until Codex is logged in.'
}
}
function Remove-TomlComment {
param([string]$Line)
$text = [string]$Line
$inSingle = $false
$inDouble = $false
$escaped = $false
for ($i = 0; $i -lt $text.Length; $i++) {
$char = $text[$i]
if ($escaped) {
$escaped = $false
continue
}
if ($inDouble -and $char -eq '\') {
$escaped = $true
continue
}
if ((-not $inDouble) -and $char -eq "'") {
$inSingle = -not $inSingle
continue
}
if ((-not $inSingle) -and $char -eq '"') {
$inDouble = -not $inDouble
continue
}
if ((-not $inSingle) -and (-not $inDouble) -and $char -eq '#') {
return $text.Substring(0, $i)
}
}
return $text
}
function Normalize-TomlKey {
param([string]$Line)
return ((Remove-TomlComment $Line) -replace '\s', '').Replace('"', '').Replace("'", '')
}
function Normalize-TomlHeader {
param([string]$Line)
$text = (Remove-TomlComment $Line).Trim()
if (-not $text.StartsWith('[') -or -not $text.EndsWith(']')) { return '' }
return Normalize-TomlKey $text
}
function Has-OpsAgentInlineTransport {
param([string]$Line)
$key = Normalize-TomlKey $Line
return ($key -match '^nodedc-ops-agent=\{.*transport=')
}
function Repair-CodexMcpConfig {
$configPath = Join-Path $env:USERPROFILE '.codex\config.toml'
if (-not (Test-Path $configPath)) { return }
try {
$raw = Get-Content -Path $configPath -Raw -Encoding UTF8
} catch {
Write-Warn "Could not read Codex config: $($_.Exception.Message)"
return
}
$next = $raw
$changed = $false
$lines = $next -split '\r?\n'
$inMcpServers = $false
$inOpsAgent = $false
$headersChanged = $false
$requiredChanged = $false
$transportChanged = $false
$hasHttpHeaders = $false
$commentDuplicateHeadersSection = $false
foreach ($line in $lines) {
$headerKey = Normalize-TomlHeader $line
if ($headerKey -eq '[mcp_servers.nodedc-ops-agent.http_headers]') {
$hasHttpHeaders = $true
break
}
}
for ($i = 0; $i -lt $lines.Count; $i++) {
$headerKey = Normalize-TomlHeader $lines[$i]
if ($commentDuplicateHeadersSection) {
if ($headerKey.StartsWith('[')) {
$commentDuplicateHeadersSection = $false
} else {
if ($lines[$i].Trim() -and -not $lines[$i].TrimStart().StartsWith('#')) {
$lines[$i] = '# ' + $lines[$i]
$headersChanged = $true
}
continue
}
}
if ($headerKey -eq '[mcp_servers.nodedc-ops-agent.headers]') {
if (-not $hasHttpHeaders) {
$lines[$i] = '[mcp_servers.nodedc-ops-agent.http_headers]'
$headersChanged = $true
$hasHttpHeaders = $true
} else {
$lines[$i] = '# ' + $lines[$i] + ' # disabled by NDC bridge installer: http_headers already exists'
$headersChanged = $true
$commentDuplicateHeadersSection = $true
}
$inOpsAgent = $false
$inMcpServers = $false
continue
}
if ($headerKey -eq '[mcp_servers.nodedc-ops-agent]') {
$inOpsAgent = $true
$inMcpServers = $false
continue
}
if ($headerKey -eq '[mcp_servers]') {
$inMcpServers = $true
$inOpsAgent = $false
continue
}
if (($inOpsAgent -or $inMcpServers) -and $headerKey.StartsWith('[')) {
$inOpsAgent = $false
$inMcpServers = $false
}
$activeLine = Remove-TomlComment $lines[$i]
if ($inOpsAgent -and $activeLine -match '^(\s*required\s*=\s*)true(\s*)$') {
$lines[$i] = $Matches[1] + 'false' + $Matches[2]
$requiredChanged = $true
}
if ($inOpsAgent -and $activeLine -match '^\s*transport\s*=') {
$lines[$i] = '# ' + $lines[$i] + ' # disabled by NDC bridge installer: Codex infers transport from url'
$transportChanged = $true
}
if ($inMcpServers -and (Has-OpsAgentInlineTransport $activeLine)) {
$lines[$i] = '# ' + $lines[$i] + ' # disabled by NDC bridge installer: Codex rejects transport here'
$transportChanged = $true
}
}
if ($headersChanged -or $requiredChanged -or $transportChanged) {
$next = $lines -join ([Environment]::NewLine)
$changed = $true
if ($headersChanged) {
Write-Ok 'Codex MCP headers normalized for nodedc-ops-agent.'
}
if ($requiredChanged) {
Write-Ok 'Codex MCP nodedc-ops-agent marked optional for bridge sessions.'
}
if ($transportChanged) {
Write-Ok 'Codex MCP transport normalized for nodedc-ops-agent.'
}
}
if (-not $changed) { return }
try {
$backupPath = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
Copy-Item -Path $configPath -Destination $backupPath -Force
Write-Ok "Codex config backup created: $backupPath"
Set-Content -Path $configPath -Value $next -Encoding UTF8
} catch {
Write-Warn "Could not update Codex config: $($_.Exception.Message)"
}
}
function ConvertTo-TomlString {
param([string]$Value)
$escaped = ([string]$Value).Replace('\', '\\').Replace('"', '\"').Replace("`r", '\r').Replace("`n", '\n')
return '"' + $escaped + '"'
}
function ConvertTo-TomlKey {
param([string]$Key)
$text = ([string]$Key).Trim()
if ($text -match '^[A-Za-z0-9_-]+$') { return $text }
return ConvertTo-TomlString $text
}
function Get-SafeMcpServerName {
param([string]$ServerName)
$candidate = ([string]$ServerName).Trim()
if ($candidate -match '^[A-Za-z0-9_-]+$') { return $candidate }
return 'nodedc_tasker'
}
function Remove-CodexMcpServerSections {
param(
[string]$Raw,
[string]$ServerName
)
if (-not $Raw) { return "" }
$mainHeader = "[mcp_servers.$ServerName]"
$headersHeader = "[mcp_servers.$ServerName.headers]"
$httpHeadersHeader = "[mcp_servers.$ServerName.http_headers]"
$lines = $Raw -split '\r?\n'
$result = New-Object System.Collections.Generic.List[string]
$skip = $false
$inMcpServers = $false
foreach ($line in $lines) {
$headerKey = Normalize-TomlHeader $line
if ($headerKey.StartsWith('[')) {
$skip = ($headerKey -eq $mainHeader -or $headerKey -eq $headersHeader -or $headerKey -eq $httpHeadersHeader)
$inMcpServers = ($headerKey -eq '[mcp_servers]')
if ($skip) { continue }
}
$activeLine = Remove-TomlComment $line
if ($inMcpServers -and $activeLine -match ("^\s*" + [regex]::Escape($ServerName) + "\s*=")) {
continue
}
if (-not $skip) {
$result.Add($line) | Out-Null
}
}
return ($result -join ([Environment]::NewLine)).TrimEnd()
}
function Ensure-McpServerConfig {
param(
[string]$ServerName,
[string]$McpUrl,
[object]$Headers,
[bool]$Required = $false,
[int]$StartupTimeoutSec = 20,
[int]$ToolTimeoutSec = 60,
[string]$Label = "AI Workspace"
)
$serverName = Get-SafeMcpServerName $ServerName
$mcpUrl = ([string]$McpUrl).Trim()
if (-not $serverName -or -not $mcpUrl) { return }
$configDir = Join-Path $env:USERPROFILE '.codex'
$configPath = Join-Path $configDir 'config.toml'
New-Item -ItemType Directory -Force -Path $configDir | Out-Null
$raw = ""
if (Test-Path $configPath) {
try {
$raw = Get-Content -Path $configPath -Raw -Encoding UTF8
} catch {
Write-Warn "Could not read Codex config for MCP setup: $($_.Exception.Message)"
return
}
}
$next = Remove-CodexMcpServerSections -Raw $raw -ServerName $serverName
$requiredValue = if ($Required) { "true" } else { "false" }
$startupTimeout = [Math]::Max(1, [int]$StartupTimeoutSec)
$toolTimeout = [Math]::Max(1, [int]$ToolTimeoutSec)
$sectionLines = New-Object System.Collections.Generic.List[string]
$sectionLines.Add("") | Out-Null
$sectionLines.Add("[mcp_servers.$serverName]") | Out-Null
$sectionLines.Add("url = $(ConvertTo-TomlString $mcpUrl)") | Out-Null
$sectionLines.Add("enabled = true") | Out-Null
$sectionLines.Add("required = $requiredValue") | Out-Null
$sectionLines.Add("startup_timeout_sec = $startupTimeout") | Out-Null
$sectionLines.Add("tool_timeout_sec = $toolTimeout") | Out-Null
$headerLines = New-Object System.Collections.Generic.List[string]
if ($Headers) {
foreach ($property in $Headers.PSObject.Properties) {
$headerName = ([string]$property.Name).Trim()
$headerValue = ([string]$property.Value).Trim()
if (-not $headerName -or -not $headerValue) { continue }
$headerLines.Add("$(ConvertTo-TomlKey $headerName) = $(ConvertTo-TomlString $headerValue)") | Out-Null
}
}
if ($headerLines.Count -gt 0) {
$sectionLines.Add("") | Out-Null
$sectionLines.Add("[mcp_servers.$serverName.http_headers]") | Out-Null
foreach ($line in $headerLines) { $sectionLines.Add($line) | Out-Null }
}
$section = $sectionLines -join ([Environment]::NewLine)
$next = ($next + $section).TrimStart()
try {
if (Test-Path $configPath) {
$backupPath = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
Copy-Item -Path $configPath -Destination $backupPath -Force
Write-Ok "Codex config backup created: $backupPath"
}
Set-Content -Path $configPath -Value $next -Encoding UTF8
Write-Ok "Codex MCP configured for $Label ($serverName)."
} catch {
Write-Warn "Could not update Codex config for MCP server $serverName`: $($_.Exception.Message)"
}
}
function Ensure-AppMcpConfigs {
$rawJson = ([string]$AppMcpServersJson).Trim()
if (-not $rawJson) { return }
try {
$servers = $rawJson | ConvertFrom-Json
} catch {
Write-Warn "Could not parse AI Workspace app MCP manifest: $($_.Exception.Message)"
return
}
if ($null -eq $servers) { return }
if ($servers -isnot [System.Array]) { $servers = @($servers) }
foreach ($server in $servers) {
if ($null -eq $server) { continue }
$serverName = [string]$server.serverName
$mcpUrl = [string]$server.url
$headers = $server.httpHeaders
if ($null -eq $headers) { $headers = $server.headers }
$label = [string]$server.appTitle
if (-not $label) { $label = [string]$server.appId }
if (-not $label) { $label = "AI Workspace" }
$startupTimeout = 20
$toolTimeout = 60
try { if ($server.startupTimeoutSec) { $startupTimeout = [int]$server.startupTimeoutSec } } catch {}
try { if ($server.toolTimeoutSec) { $toolTimeout = [int]$server.toolTimeoutSec } } catch {}
Ensure-McpServerConfig `
-ServerName $serverName `
-McpUrl $mcpUrl `
-Headers $headers `
-Required ($server.required -eq $true) `
-StartupTimeoutSec $startupTimeout `
-ToolTimeoutSec $toolTimeout `
-Label $label
}
}
function Ensure-OpsMcpConfig {
$mcpUrl = ([string]$OpsMcpUrl).Trim()
$mcpToken = ([string]$OpsMcpToken).Trim()
if (-not $mcpUrl -or -not $mcpToken) { return }
$serverName = Get-SafeMcpServerName $OpsMcpServerName
$authorization = "Bearer $mcpToken"
$headers = [pscustomobject]@{
Authorization = $authorization
Accept = "application/json"
"MCP-Protocol-Version" = "2025-06-18"
}
Ensure-McpServerConfig -ServerName $serverName -McpUrl $mcpUrl -Headers $headers -Required $false -StartupTimeoutSec 20 -ToolTimeoutSec 60 -Label "AI Workspace Ops"
}
function Get-WorkspacePath {
$workspacePath = Get-DefaultWorkspaceCandidate
Write-Ok "Codex workspace: $workspacePath"
return $workspacePath
}
function Write-WorkerFiles {
param(
[string]$Root,
[string]$WorkspacePath
)
Write-Step 'Writing bridge worker'
$logs = Join-Path $Root 'logs'
New-Item -ItemType Directory -Force -Path $Root | Out-Null
New-Item -ItemType Directory -Force -Path $logs | Out-Null
if (-not (Test-Path $WorkspacePath)) {
New-Item -ItemType Directory -Force -Path $WorkspacePath | Out-Null
}
$workerPath = Join-Path $Root 'worker.mjs'
$ndcAgentMcpServerPath = Join-Path $Root 'ndcAgentMcpServer.mjs'
$startScript = Join-Path $Root 'start-bridge.ps1'
$watchdogScript = Join-Path $Root 'watchdog-bridge.ps1'
$configPath = Join-Path $Root 'config.json'
$npmPrefix = Get-NpmGlobalPrefix
$nodeSource = Resolve-CommandSource 'node'
$nodeDir = [System.IO.Path]::GetDirectoryName($nodeSource)
$logPath = Join-Path $logs 'bridge.log'
$activeMirror = Join-Path $WorkspacePath '.nodedc\ai-workspace'
$resolvedMachineName = if ($MachineName) { $MachineName } else { $env:COMPUTERNAME }
$hubUrlCandidates = @(Get-HubUrlCandidates)
$hubUrlsText = ($hubUrlCandidates -join ',')
$workerCode = @'
#!/usr/bin/env node
import http from 'node:http'
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs/promises'
import fssync from 'node:fs'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const HOST = process.env.AI_BRIDGE_HOST || '0.0.0.0'
const PORT = Number(process.env.AI_BRIDGE_PORT || 8787)
const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
const SESSION_INDEX = process.env.AI_BRIDGE_SESSION_INDEX || path.join(CODEX_HOME, 'session_index.jsonl')
const SESSIONS_DIR = process.env.AI_BRIDGE_SESSIONS_DIR || path.join(CODEX_HOME, 'sessions')
const CODEX_BIN = process.env.AI_BRIDGE_CODEX_BIN || 'codex'
const DEFAULT_CODEX_ARGS = '["exec","--json","--skip-git-repo-check","-c","model_reasoning_summary=detailed","-c","model_supports_reasoning_summaries=true","-c","use_experimental_reasoning_summary=true","-c","hide_agent_reasoning=false","-c","show_raw_agent_reasoning=false","-"]'
const CODEX_ARGS = parseArgs(process.env.AI_BRIDGE_CODEX_ARGS || DEFAULT_CODEX_ARGS)
const CODEX_RESUME_ARGS = parseArgs(process.env.AI_BRIDGE_CODEX_RESUME_ARGS || '')
const CODEX_CWD = process.env.AI_BRIDGE_CODEX_CWD || process.cwd()
const MESSAGE_TIMEOUT_MS = Number(process.env.AI_BRIDGE_MESSAGE_TIMEOUT_MS || 12 * 60 * 60 * 1000)
const RESUME_TIMEOUT_MS = Number(process.env.AI_BRIDGE_RESUME_TIMEOUT_MS || MESSAGE_TIMEOUT_MS)
const MAX_BODY_BYTES = Number(process.env.AI_BRIDGE_MAX_BODY_BYTES || 1024 * 1024)
const MAX_STDIO_BYTES = Number(process.env.AI_BRIDGE_MAX_STDIO_BYTES || 2 * 1024 * 1024)
const MAX_CONVERSATIONS = Number(process.env.AI_BRIDGE_MAX_CONVERSATIONS || 100)
const MAX_SESSION_FILES = Number(process.env.AI_BRIDGE_MAX_SESSION_FILES || 5000)
const MAX_SESSION_FILE_BYTES = Number(process.env.AI_BRIDGE_MAX_SESSION_FILE_BYTES || 64 * 1024 * 1024)
const MAX_MESSAGES_PER_CONVERSATION = Number(process.env.AI_BRIDGE_MAX_MESSAGES_PER_CONVERSATION || 300)
const MAX_MESSAGE_CHARS = Number(process.env.AI_BRIDGE_MAX_MESSAGE_CHARS || 12000)
const MAX_WORKSPACES = Number(process.env.AI_BRIDGE_MAX_WORKSPACES || 120)
const REASONING_SUMMARY_DELTA_MIN_CHARS = Number(process.env.AI_BRIDGE_REASONING_SUMMARY_DELTA_MIN_CHARS || 80)
const REASONING_SUMMARY_DELTA_MAX_WAIT_MS = Number(process.env.AI_BRIDGE_REASONING_SUMMARY_DELTA_MAX_WAIT_MS || 1800)
const CODEX_STOP_GRACE_MS = Number(process.env.AI_BRIDGE_CODEX_STOP_GRACE_MS || 2500)
const STALE_RUNNING_SESSION_MS = Number(process.env.AI_BRIDGE_STALE_RUNNING_SESSION_MS || 10 * 60 * 1000)
const FINAL_MESSAGE_PHASES = new Set(['final', 'final_answer', 'answer'])
const BRIDGE_FILE = fileURLToPath(import.meta.url)
const BRIDGE_DIR = path.dirname(BRIDGE_FILE)
const NDC_AGENT_CODEX_HOME = path.resolve(process.env.AI_BRIDGE_NDC_AGENT_CODEX_HOME || path.join(BRIDGE_DIR, 'codex-home-ndc-agent-core'))
const RUN_CODEX_HOME_ROOT = path.resolve(process.env.AI_BRIDGE_RUN_CODEX_HOME_ROOT || path.join(BRIDGE_DIR, 'codex-home-runs'))
const DEFAULT_NDC_AGENT_MCP_API_BASE = 'http://127.0.0.1:3001/api/ndc-agent-mcp'
const HUB_URL = String(process.env.AI_BRIDGE_HUB_URL || '').trim()
const HUB_URLS = parseHubUrls(process.env.AI_BRIDGE_HUB_URLS || '', HUB_URL)
const PAIRING_CODE = String(process.env.AI_BRIDGE_PAIRING_CODE || '').trim()
const MACHINE_NAME = String(process.env.AI_BRIDGE_MACHINE_NAME || os.hostname()).trim()
const HUB_RECONNECT_MS = Number(process.env.AI_BRIDGE_HUB_RECONNECT_MS || 5000)
const HUB_CONNECT_TIMEOUT_MS = Number(process.env.AI_BRIDGE_HUB_CONNECT_TIMEOUT_MS || 10000)
const hubState = {
mode: Boolean(HUB_URLS.length && PAIRING_CODE),
connected: false,
url: '',
pairingCode: PAIRING_CODE,
candidates: HUB_URLS,
lastConnectedAt: '',
lastDisconnectedAt: '',
lastError: '',
}
const ACTIVE_MIRROR_ENABLED = process.env.AI_BRIDGE_ACTIVE_MIRROR !== '0'
const ACTIVE_MIRROR_DIR = resolveWorkspacePath(process.env.AI_BRIDGE_ACTIVE_MIRROR_DIR || path.join('.nodedc', 'ai-workspace'))
const ACTIVE_MIRROR_CURRENT_FILE = path.join(ACTIVE_MIRROR_DIR, 'current.md')
const ACTIVE_MIRROR_EVENTS_FILE = path.join(ACTIVE_MIRROR_DIR, 'events.jsonl')
const ACTIVE_MIRROR_MAX_EVENTS = Number(process.env.AI_BRIDGE_ACTIVE_MIRROR_MAX_EVENTS || 80)
const ACTIVE_MIRROR_OPEN_IDE = process.env.AI_BRIDGE_ACTIVE_MIRROR_OPEN_IDE !== '0'
const ACTIVE_MIRROR_IDE_BIN = String(process.env.AI_BRIDGE_ACTIVE_MIRROR_IDE_BIN || 'code').trim()
const NDC_AGENT_MCP_SERVER = resolveWorkspacePath(defaultNdcAgentMcpServerPath())
const NDC_AGENT_MCP_REF_PATH = process.env.AI_BRIDGE_NDC_AGENT_MCP_REF_PATH ||
path.resolve(path.dirname(NDC_AGENT_MCP_SERVER), '..', '..', '..', 'tools', 'NDCMCP')
const activeCodexRunsByRequest = new Map()
const activeCodexRunsByThread = new Map()
function defaultNdcAgentMcpServerPath() {
const explicit = String(process.env.AI_BRIDGE_NDC_AGENT_MCP_SERVER || '').trim()
if (explicit) return explicit
const installed = path.join(BRIDGE_DIR, 'ndcAgentMcpServer.mjs')
if (fssync.existsSync(installed)) return installed
const cwdLeaf = path.basename(String(CODEX_CWD || '').replace(/[\\/]+$/, ''))
if (cwdLeaf === 'nodedc-source') return path.join('server', 'aiWorkspace', 'ndcAgentMcpServer.mjs')
return path.join('nodedc-source', 'server', 'aiWorkspace', 'ndcAgentMcpServer.mjs')
}
function resolveWorkspacePath(value) {
const text = String(value || '').trim()
if (!text) return CODEX_CWD
return path.isAbsolute(text) ? text : path.resolve(CODEX_CWD, text)
}
async function resolveExistingDirectory(value, fallback = CODEX_CWD) {
const fallbackPath = resolveWorkspacePath(fallback)
const candidate = resolveWorkspacePath(value || fallback)
try {
const stat = await fs.stat(candidate)
if (stat.isDirectory()) return candidate
} catch {}
return fallbackPath
}
function workspaceTitle(value) {
const normalized = String(value || '').replace(/[\\/]+$/, '')
return path.basename(normalized) || normalized || 'Workspace'
}
function workspaceId(value) {
return String(value || '').trim()
}
function parseHubUrls(raw, primary = '') {
const values = []
const push = (value) => {
const text = String(value || '').trim()
if (!text) return
try {
const url = new URL(text)
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') return
url.username = ''
url.password = ''
const clean = url.toString().replace(/\/+$/, '')
if (!values.includes(clean)) values.push(clean)
} catch {}
}
push(primary)
const text = String(raw || '').trim()
if (!text) return values
try {
const parsed = JSON.parse(text)
if (Array.isArray(parsed)) {
parsed.forEach(push)
return values
}
} catch {}
text.split(/[\n,;]+/).forEach(push)
return values
}
function nowMs() {
return Date.now()
}
function elapsedLabel(startedAt) {
const elapsed = Math.max(0, nowMs() - Number(startedAt || nowMs()))
if (elapsed < 1000) return `${elapsed}ms`
return `${(elapsed / 1000).toFixed(1)}s`
}
function elapsedFromIso(value) {
const startedAt = Date.parse(String(value || ''))
if (!Number.isFinite(startedAt)) return ''
return elapsedLabel(startedAt)
}
function cleanRunKey(value, limit = 160) {
return String(value || '').trim().slice(0, limit)
}
function registerActiveCodexRun(control = {}, child, onEvent = () => {}) {
const requestId = cleanRunKey(control.requestId, 120)
const threadId = cleanRunKey(control.threadId, 160)
if (!requestId && !threadId) return null
const run = {
requestId,
threadId,
child,
onEvent,
closed: false,
stopRequested: false,
stopTimer: null,
stop() {
if (run.closed) return false
if (run.stopRequested) return true
run.stopRequested = true
try {
run.onEvent({ kind: 'stopped', message: 'Codex stop requested.' })
} catch {}
try {
run.child.kill('SIGTERM')
} catch {}
run.stopTimer = setTimeout(() => {
if (run.closed) return
try {
run.child.kill('SIGKILL')
} catch {}
}, CODEX_STOP_GRACE_MS)
return true
},
unregister() {
run.closed = true
if (run.stopTimer) clearTimeout(run.stopTimer)
if (requestId && activeCodexRunsByRequest.get(requestId) === run) activeCodexRunsByRequest.delete(requestId)
if (threadId && activeCodexRunsByThread.get(threadId) === run) activeCodexRunsByThread.delete(threadId)
},
}
if (requestId) activeCodexRunsByRequest.set(requestId, run)
if (threadId) activeCodexRunsByThread.set(threadId, run)
return run
}
function stopActiveCodexRun(payload = {}) {
const requestId = cleanRunKey(payload?.requestId, 120)
const threadId = cleanRunKey(payload?.threadId, 160)
const run = (requestId ? activeCodexRunsByRequest.get(requestId) : null)
|| (threadId ? activeCodexRunsByThread.get(threadId) : null)
|| null
if (!run) {
return {
ok: true,
stopped: false,
reason: 'active_codex_run_not_found',
requestId,
threadId,
}
}
const stopped = run.stop()
return {
ok: true,
stopped,
requestId: run.requestId,
threadId: run.threadId,
}
}
function parseArgs(raw) {
const text = String(raw || '').trim()
if (!text) return []
try {
const parsed = JSON.parse(text)
if (Array.isArray(parsed)) return parsed.map((item) => String(item))
} catch {}
return text.split(/\s+/).filter(Boolean)
}
function sendJson(res, status, payload) {
const body = JSON.stringify(payload)
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': process.env.AI_BRIDGE_ALLOW_ORIGIN || '*',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
})
res.end(body)
}
function readBody(req) {
return new Promise((resolve, reject) => {
let size = 0
let body = ''
req.setEncoding('utf8')
req.on('data', (chunk) => {
size += Buffer.byteLength(chunk)
if (size > MAX_BODY_BYTES) {
reject(new Error('body_too_large'))
req.destroy()
return
}
body += chunk
})
req.on('end', () => {
if (!body.trim()) return resolve({})
try {
resolve(JSON.parse(body))
} catch {
reject(new Error('json_invalid'))
}
})
req.on('error', reject)
})
}
async function readConversations() {
let raw = ''
try {
raw = await fs.readFile(SESSION_INDEX, 'utf8')
} catch {
return []
}
const conversations = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
try {
const item = JSON.parse(line)
const id = String(item.id || '').trim()
if (!id) return null
return {
id,
title: String(item.thread_name || item.title || id).trim(),
updatedAt: item.updated_at || item.updatedAt || null,
}
} catch {
return null
}
})
.filter(Boolean)
.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))
.slice(0, MAX_CONVERSATIONS)
const sessionFiles = await listSessionFiles(SESSIONS_DIR)
await Promise.all(conversations.map(async (conversation) => {
const sessionFile = await findSessionFile(sessionFiles, conversation)
if (!sessionFile) {
conversation.messages = []
return
}
conversation.sessionFile = sessionFile
const workspaceMeta = await readSessionWorkspaceMeta(sessionFile)
if (workspaceMeta?.cwd) {
conversation.workspacePath = workspaceMeta.cwd
conversation.workspaceTitle = workspaceTitle(workspaceMeta.cwd)
conversation.workspaceUpdatedAt = workspaceMeta.updatedAt || conversation.updatedAt || null
}
const sessionState = await readSessionState(sessionFile)
conversation.messages = sessionState.messages
conversation.status = sessionState.status
conversation.running = sessionState.running
conversation.sessionUpdatedAt = sessionState.updatedAt
}))
return conversations
}
function buildPrompt(payload) {
const context = payload?.context && typeof payload.context === 'object' ? payload.context : {}
const runProfile = payload?.runProfile && typeof payload.runProfile === 'object' ? payload.runProfile : {}
const runProfilePolicyPrompt = String(runProfile.policyPrompt || '').trim()
const userMessage = String(payload?.message || '').trim()
const history = payload?.resume ? [] : normalizeHistory(payload?.history || [])
const isNdcAgentCore = String(context.modeId || '').trim() === 'ndc-agent-core'
const isOpsMode = String(context.modeId || '').trim() === 'ops'
const guardInstruction = String(context.guardInstruction || payload?.guardInstruction || '').trim()
const missingContext = Array.isArray(context.missingContext)
? context.missingContext.map((item) => String(item || '').trim()).filter(Boolean)
: []
const contextReady = context.contextReady !== false && missingContext.length === 0
const engineContext = context.engineContext && typeof context.engineContext === 'object'
? context.engineContext
: context.contexts?.engine && typeof context.contexts.engine === 'object'
? context.contexts.engine
: {}
const opsContext = context.opsContext && typeof context.opsContext === 'object'
? context.opsContext
: context.contexts?.ops && typeof context.contexts.ops === 'object'
? context.contexts.ops
: {}
const ndcAgentMcpApiBaseUrl = deriveNdcAgentMcpApiBaseUrl(context)
const lines = [
'You are connected to NODE.DC AI Workspace through AI Workspace Bridge.',
'',
'Context:',
`- active surface: ${context.surface || 'unknown'}`,
`- source surface: ${context.sourceSurface || context.surface || 'unknown'}`,
`- mode: ${context.modeTitle || context.modeId || 'unknown'}`,
`- workflow: ${context.workflowTitle || context.workflowId || 'unknown'}`,
`- agent node: ${context.agentNodeTitle || context.agentNodeId || 'unknown'}`,
`- role: ${context.roleTitle || context.roleId || 'unknown'}`,
`- workspace: ${context.workspacePath || payload?.workspacePath || CODEX_CWD}`,
`- context ready: ${contextReady ? 'yes' : 'no'}`,
...missingContext.length ? [`- missing context: ${missingContext.join(', ')}`] : [],
`- access mode: ${context.accessMode || 'chat'}`,
'',
'Cross-platform contexts:',
`- Engine workflow: ${engineContext.workflowTitle || engineContext.workflowId || 'not selected'}`,
`- Engine agent node: ${engineContext.agentNodeTitle || engineContext.agentNodeId || 'not selected'}`,
`- Engine role: ${engineContext.roleTitle || engineContext.roleId || 'not selected'}`,
`- Ops workspace: ${opsContext.opsWorkspaceTitle || opsContext.opsWorkspaceSlug || opsContext.opsWorkspaceId || 'not selected'}`,
`- Ops project: ${opsContext.opsProjectTitle || opsContext.opsProjectIdentifier || opsContext.opsProjectId || 'not selected'}`,
'- the active/source surface is where the user opened the assistant; it is not a write boundary by itself',
'- choose the write target from the selected per-app contexts and the user request; cross-app work is allowed when the relevant context is selected',
'- do not refuse Engine work only because the source surface is Ops, and do not refuse Ops work only because the mode is NDC Agent Core',
'- use the user language for the final answer and public progress/reasoning summaries; if the user writes in Russian, write them in Russian',
'- never mention internal n8n/N8N names, endpoints, environment variables, schemas, or runtime identifiers in public answers; call the product and workflow runtime NDC',
'- while working, provide concise public progress/reasoning summaries when the Codex runtime exposes them',
'- public progress/reasoning summaries should say what you are checking, reading, comparing, or deciding',
'- keep public progress/reasoning summaries user-facing; do not include raw command lines, JSON, hidden chain-of-thought, or bridge transport details',
...isNdcAgentCore ? [
'',
'NDC Agent Core mode contract:',
...!contextReady ? [
'- The NDC Agent Core context is incomplete. Chat, explanations, reading, and Ops work are still allowed when the required target for that work is selected.',
'- Do not claim that an Engine agent node is selected when it is missing.',
'- Do not create, edit, patch, deploy, or write Engine workflow graph changes until the missing Engine agent node is selected.',
'- If the user asks for Engine development or graph edits, explain briefly that selecting an Engine agent node is required for that specific write action.',
] : [
'- Treat the selected Engine agent node as the only writable Engine workflow target.',
'- Do not write directly to the NDC core runtime and do not modify deployed runtime workflows by hand.',
'- The Engine second-level workflow file is the source of truth for edits.',
`- Engine API base for this target: ${ndcAgentMcpApiBaseUrl}`,
`- Selected workflowId: ${context.workflowId || 'unknown'}`,
`- Selected agentNodeId: ${context.agentNodeId || 'unknown'}`,
'- Read the second-level graph with GET /subworkflow?workflowId=<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.',
'- 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,
] : [],
...runProfilePolicyPrompt ? [
'',
runProfilePolicyPrompt,
] : [],
'',
...history.length ? [
'Recent conversation from AI Workspace:',
...history.map((message) => `${message.role}: ${message.text}`),
'',
] : [],
'User message:',
userMessage,
]
return lines.join('\n')
}
async function listSessionFiles(rootDir) {
const files = []
async function walk(dir) {
if (files.length >= MAX_SESSION_FILES) return
let entries = []
try {
entries = await fs.readdir(dir, { withFileTypes: true })
} catch {
return
}
entries.sort((a, b) => b.name.localeCompare(a.name))
for (const entry of entries) {
if (files.length >= MAX_SESSION_FILES) return
const itemPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
await walk(itemPath)
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
files.push(itemPath)
}
}
}
await walk(rootDir)
return files
}
async function findSessionFile(files, conversation) {
const id = String(conversation?.id || '').trim()
if (!id) return ''
const updatedAt = String(conversation?.updatedAt || '').trim()
const dateMatch = updatedAt.match(/^(\d{4})-(\d{2})-(\d{2})/)
if (dateMatch) {
const dateDir = path.join(SESSIONS_DIR, dateMatch[1], dateMatch[2], dateMatch[3])
try {
const entries = await fs.readdir(dateDir)
const match = entries.find((name) => name.endsWith('.jsonl') && name.includes(id))
if (match) return path.join(dateDir, match)
} catch {}
}
return files.find((file) => path.basename(file).includes(id)) || ''
}
async function readTextFileOrTail(filePath) {
const stat = await fs.stat(filePath)
if (stat.size <= MAX_SESSION_FILE_BYTES) return fs.readFile(filePath, 'utf8')
const handle = await fs.open(filePath, 'r')
try {
const size = Math.min(stat.size, MAX_SESSION_FILE_BYTES)
const buffer = Buffer.alloc(size)
await handle.read(buffer, 0, size, Math.max(0, stat.size - size))
const text = buffer.toString('utf8')
return text.slice(text.indexOf('\n') + 1)
} finally {
await handle.close()
}
}
async function readTextFileHead(filePath, maxBytes = 256 * 1024) {
const stat = await fs.stat(filePath)
const size = Math.min(stat.size, maxBytes)
if (size <= 0) return ''
const handle = await fs.open(filePath, 'r')
try {
const buffer = Buffer.alloc(size)
await handle.read(buffer, 0, size, 0)
return buffer.toString('utf8')
} finally {
await handle.close()
}
}
async function readSessionWorkspaceMeta(filePath) {
let text = ''
try {
text = await readTextFileHead(filePath)
} catch {
return null
}
for (const line of text.split(/\r?\n/)) {
if (!line.trim()) continue
try {
const item = JSON.parse(line)
const payload = item?.payload || {}
if (item?.type === 'session_meta' && payload?.cwd) {
return {
cwd: String(payload.cwd || '').trim(),
updatedAt: payload.timestamp || item.timestamp || null,
}
}
const contentText = readContentText(payload?.content)
const match = String(contentText || '').match(/<cwd>([\s\S]*?)<\/cwd>/i)
if (match?.[1]) {
return {
cwd: match[1].trim(),
updatedAt: item.timestamp || null,
}
}
} catch {}
}
return null
}
function timestampMs(value) {
const parsed = Date.parse(String(value || ''))
return Number.isFinite(parsed) ? parsed : 0
}
function messageTimestampMs(message) {
return timestampMs(message?.createdAt)
}
function itemTimestampMs(item) {
return timestampMs(item?.timestamp || item?.createdAt || item?.created_at || item?.payload?.createdAt || item?.payload?.created_at)
}
function isFinalAssistantMessage(message) {
if (message?.role !== 'assistant') return false
const phase = String(message.phase || '').trim().toLowerCase()
return phase ? FINAL_MESSAGE_PHASES.has(phase) : true
}
async function readSessionState(filePath) {
let text = ''
let updatedAt = null
try {
const stat = await fs.stat(filePath)
updatedAt = stat.mtime?.toISOString?.() || null
text = await readTextFileOrTail(filePath)
} catch {
return { messages: [], status: 'unknown', running: false, updatedAt }
}
const responseMessages = []
const eventMessages = []
let latestUserAt = 0
let latestAssistantFinalAt = 0
let latestActivityAt = 0
let latestFailureAt = 0
for (const line of text.split(/\r?\n/)) {
if (!line.trim()) continue
let item = null
try {
item = JSON.parse(line)
} catch {
continue
}
const responseMessage = extractResponseMessage(item)
if (responseMessage) {
responseMessages.push(responseMessage)
const at = messageTimestampMs(responseMessage)
latestActivityAt = Math.max(latestActivityAt, at)
if (responseMessage.role === 'user') latestUserAt = Math.max(latestUserAt, at)
if (isFinalAssistantMessage(responseMessage)) latestAssistantFinalAt = Math.max(latestAssistantFinalAt, at)
continue
}
const eventMessage = extractEventMessage(item)
if (eventMessage) {
eventMessages.push(eventMessage)
const at = messageTimestampMs(eventMessage)
latestActivityAt = Math.max(latestActivityAt, at)
if (eventMessage.role === 'user') latestUserAt = Math.max(latestUserAt, at)
if (isFinalAssistantMessage(eventMessage)) latestAssistantFinalAt = Math.max(latestAssistantFinalAt, at)
}
const typeText = `${item?.type || ''} ${item?.payload?.type || ''}`.toLowerCase()
if (/error|failed|interrupted|aborted/.test(typeText)) latestFailureAt = Math.max(latestFailureAt, itemTimestampMs(item))
}
const latestResponseAt = responseMessages.reduce((max, message) => Math.max(max, messageTimestampMs(message)), 0)
const liveEventMessages = responseMessages.length
? eventMessages.filter((message) => messageTimestampMs(message) > latestResponseAt)
: eventMessages
const messages = dedupeMessages([...responseMessages, ...liveEventMessages]
.sort((a, b) => messageTimestampMs(a) - messageTimestampMs(b)))
.slice(-MAX_MESSAGES_PER_CONVERSATION)
const failed = latestFailureAt > latestUserAt
const rawRunning = latestUserAt > Math.max(latestAssistantFinalAt, latestFailureAt)
const updatedAtMs = timestampMs(updatedAt)
const staleRunning = rawRunning && Number.isFinite(updatedAtMs) && Date.now() - updatedAtMs > STALE_RUNNING_SESSION_MS
const running = rawRunning && !staleRunning
const status = running ? 'running' : failed ? 'failed' : latestActivityAt ? 'completed' : 'unknown'
return { messages, status, running, updatedAt }
}
async function readSessionMessages(filePath) {
const state = await readSessionState(filePath)
return state.messages
}
function extractResponseMessage(item) {
const payload = item?.payload || {}
if (item?.type !== 'response_item' || payload?.type !== 'message') return null
const role = normalizeRole(payload.role)
if (!role) return null
const text = normalizeDisplayMessageText(role, readContentText(payload.content ?? payload.text ?? payload.message))
if (!text || shouldSkipConversationText(text)) return null
return {
id: String(payload.id || item.id || `${role}-${Date.now()}-${Math.random()}`),
role,
text,
phase: String(payload.phase || item.phase || '').trim(),
createdAt: payload.created_at || payload.createdAt || item.created_at || item.createdAt || item.timestamp || null,
}
}
function extractEventMessage(item) {
const payload = item?.payload || {}
if (item?.type !== 'event_msg') return null
const type = String(payload.type || '')
const role = type === 'user_message' ? 'user' : type === 'agent_message' ? 'assistant' : ''
if (!role) return null
const text = normalizeDisplayMessageText(role, payload.message || payload.text || '')
if (!text || shouldSkipConversationText(text)) return null
return {
id: String(payload.id || item.id || `${role}-${Date.now()}-${Math.random()}`),
role,
text,
phase: String(payload.phase || item.phase || '').trim(),
createdAt: payload.created_at || payload.createdAt || item.created_at || item.createdAt || item.timestamp || null,
}
}
function readContentText(content) {
if (typeof content === 'string') return content
if (!Array.isArray(content)) return ''
return content
.map((part) => {
if (typeof part === 'string') return part
if (typeof part?.text === 'string') return part.text
if (typeof part?.content === 'string') return part.content
return ''
})
.filter(Boolean)
.join('\n')
}
function normalizeRole(role) {
const value = String(role || '').trim().toLowerCase()
if (value === 'user') return 'user'
if (value === 'assistant') return 'assistant'
return ''
}
function normalizeMessageText(value) {
const text = String(value || '').replace(/\r\n/g, '\n').trim()
if (!text) return ''
return text.length > MAX_MESSAGE_CHARS ? `${text.slice(0, MAX_MESSAGE_CHARS).trimEnd()}\n\n[truncated]` : text
}
function normalizeDisplayMessageText(role, value) {
const text = normalizeMessageText(value)
if (role !== 'user') return text
const clean = text.replace(/^user\s*\n/i, '').trim()
if (!clean.includes('You are connected to NODE.DC AI Workspace through AI Workspace Bridge.')) return text
const match = clean.match(/(?:^|\n)User message:\s*\n?([\s\S]*)$/i)
return normalizeMessageText(match?.[1] || '')
}
function shouldSkipConversationText(text) {
return /^\s*<(environment_context|permissions instructions|app-context|collaboration_mode|apps_instructions|skills_instructions|plugins_instructions|turn_aborted)\b/i.test(text)
}
function dedupeMessages(messages) {
const result = []
for (const message of messages) {
const previous = result[result.length - 1]
if (previous && previous.role === message.role && previous.text === message.text) continue
result.push(message)
}
return result
}
function normalizeHistory(history) {
if (!Array.isArray(history)) return []
return history
.slice(-40)
.map((message) => ({
role: normalizeRole(message?.role),
text: normalizeMessageText(message?.text || message?.content || ''),
}))
.filter((message) => message.role && message.text)
}
function cleanSessionId(value) {
const text = String(value || '').trim()
if (!text || text.startsWith('ai-') || text.includes(':')) return ''
return /^[A-Za-z0-9._-]{8,160}$/.test(text) ? text : ''
}
function buildResumeArgs(sessionId) {
const clean = cleanSessionId(sessionId)
if (!clean) return CODEX_ARGS
if (CODEX_RESUME_ARGS.length) {
return CODEX_RESUME_ARGS.map((item) => item.replace(/\{sessionId\}/g, clean))
}
const stdinIndex = CODEX_ARGS.lastIndexOf('-')
const base = stdinIndex >= 0
? CODEX_ARGS.filter((_, index) => index !== stdinIndex)
: [...CODEX_ARGS]
if (base[0] !== 'exec') return CODEX_ARGS
return [...base, 'resume', clean, '-']
}
function isPlainObject(value) {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
function isNdcAgentCorePayload(payload) {
const context = isPlainObject(payload?.context) ? payload.context : {}
return String(context.modeId || '').trim() === 'ndc-agent-core'
}
function insertBeforePromptStdin(args, extras) {
const base = Array.isArray(args) ? [...args] : []
const stdinIndex = base.lastIndexOf('-')
if (stdinIndex < 0) return [...base, ...extras]
return [...base.slice(0, stdinIndex), ...extras, ...base.slice(stdinIndex)]
}
function buildNdcAgentMcpContext(payload, cwd) {
const context = payload?.context && typeof payload.context === 'object' ? payload.context : {}
const apiBaseUrl = deriveNdcAgentMcpApiBaseUrl(context)
return {
workflowId: String(context.workflowId || '').trim(),
workflowTitle: String(context.workflowTitle || '').trim(),
agentNodeId: String(context.agentNodeId || '').trim(),
agentNodeTitle: String(context.agentNodeTitle || '').trim(),
roleId: String(context.roleId || '').trim(),
roleTitle: String(context.roleTitle || '').trim(),
ndcAgentMcpApiBaseUrl: apiBaseUrl.replace(/\/+$/, ''),
schemaVersion: 'v2.3.2',
n8nMcpRefPath: NDC_AGENT_MCP_REF_PATH,
workspacePath: cwd,
}
}
function deriveNdcAgentMcpApiBaseUrl(context) {
const explicit = cleanApiBaseUrl(context?.ndcAgentMcpApiBaseUrl || context?.engineApiBaseUrl || '')
const hubDerived = deriveNdcAgentMcpApiBaseUrlFromHub()
if (!explicit) return hubDerived || DEFAULT_NDC_AGENT_MCP_API_BASE
if (!isHttpUrl(explicit)) return hubDerived || DEFAULT_NDC_AGENT_MCP_API_BASE
if (hubDerived && isProtectedNodedcEngineUrl(explicit)) return hubDerived
if (isLoopbackUrl(explicit) && hubDerived) return hubDerived
if (!explicit.endsWith('/api/ndc-agent-mcp')) return `${explicit.replace(/\/+$/, '')}/api/ndc-agent-mcp`
return explicit
}
function cleanApiBaseUrl(value) {
const text = String(value || '').trim().replace(/\/+$/, '')
return text || ''
}
function isLoopbackUrl(value) {
try {
const url = new URL(value)
const host = url.hostname.toLowerCase()
return host === 'localhost' || host === '0.0.0.0' || host === '::1' || host.startsWith('127.')
} catch {
return false
}
}
function isHttpUrl(value) {
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
function deriveNdcAgentMcpApiBaseUrlFromHub() {
return deriveNdcAgentMcpBaseFromHubUrl(hubState.url)
|| HUB_URLS.map(deriveNdcAgentMcpBaseFromHubUrl).find(Boolean)
|| ''
}
function deriveNdcAgentMcpBaseFromHubUrl(value) {
try {
const url = new URL(String(value || '').trim())
const hostname = url.hostname.toLowerCase()
if (!hostname) return ''
if (url.protocol === 'wss:') url.protocol = 'https:'
else if (url.protocol === 'ws:') url.protocol = 'http:'
else if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''
url.pathname = ''
url.search = ''
url.hash = ''
const origin = url.toString().replace(/\/+$/, '')
if (hostname === 'ai-hub.nodedc.ru') {
const pairingCode = cleanHubPairingCode(PAIRING_CODE)
return pairingCode ? `${origin}/api/ai-workspace/hub/v1/ndc-agent-mcp/${pairingCode}` : ''
}
return `${origin}/api/ndc-agent-mcp`
} catch {
return ''
}
}
function cleanHubPairingCode(value) {
return String(value || '').trim().toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 32)
}
function isProtectedNodedcEngineUrl(value) {
try {
const url = new URL(String(value || '').trim())
return url.hostname.toLowerCase() === 'engine.nodedc.ru'
} catch {
return false
}
}
function tomlString(value) {
return JSON.stringify(String(value || ''))
}
function tomlKey(value) {
const key = String(value || '').trim()
if (/^[A-Za-z0-9_-]+$/.test(key)) return key
return JSON.stringify(key)
}
function cleanMcpServerName(value) {
const text = String(value || '').trim().replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '')
return text.slice(0, 80)
}
function cleanRunDirectoryName(value) {
const text = String(value || '').trim().replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^_+|_+$/g, '')
return (text || `run-${Date.now()}`).slice(0, 120)
}
function positiveInteger(value, fallback, min = 1, max = 600) {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return fallback
return Math.max(min, Math.min(max, Math.trunc(parsed)))
}
function runProfileFromPayload(payload) {
return isPlainObject(payload?.runProfile) ? payload.runProfile : {}
}
function runProfileMcpServers(payload) {
const runProfile = runProfileFromPayload(payload)
const toolProfile = isPlainObject(runProfile.toolProfile) ? runProfile.toolProfile : {}
const rawServers = Array.isArray(toolProfile.mcpServers) ? toolProfile.mcpServers : []
const byName = new Map()
for (const raw of rawServers) {
const server = sanitizeRunMcpServer(raw)
if (!server) continue
byName.set(server.serverName, server)
}
return Array.from(byName.values())
}
function sanitizeRunMcpServer(raw) {
if (!isPlainObject(raw) || raw.enabled === false) return null
const serverName = cleanMcpServerName(raw.serverName || raw.server_name || raw.name)
const url = String(raw.url || '').trim()
if (!serverName || !isHttpUrl(url)) return null
return {
serverName,
url,
required: raw.required === true,
startupTimeoutSec: positiveInteger(raw.startupTimeoutSec || raw.startup_timeout_sec, 20, 1, 120),
toolTimeoutSec: positiveInteger(raw.toolTimeoutSec || raw.tool_timeout_sec, 60, 1, 600),
httpHeaders: sanitizeRunMcpHeaders(raw.httpHeaders || raw.http_headers || raw.headers),
}
}
function sanitizeRunMcpHeaders(value) {
if (!isPlainObject(value)) return {}
const out = {}
for (const [key, item] of Object.entries(value)) {
const headerName = String(key || '').trim()
const headerValue = String(item ?? '').trim()
if (!headerName || !headerValue) continue
if (/[\r\n\0]/.test(headerName) || /[\r\n\0]/.test(headerValue)) continue
out[headerName] = headerValue
}
return out
}
function runtimeCodexConfig() {
return [
'approval_policy = "never"',
'sandbox_mode = "danger-full-access"',
].join('\n')
}
function dynamicMcpServerConfig(server) {
const lines = [
`[mcp_servers.${tomlKey(server.serverName)}]`,
`url = ${tomlString(server.url)}`,
'enabled = true',
`required = ${server.required ? 'true' : 'false'}`,
`startup_timeout_sec = ${positiveInteger(server.startupTimeoutSec, 20, 1, 120)}`,
`tool_timeout_sec = ${positiveInteger(server.toolTimeoutSec, 60, 1, 600)}`,
]
const headers = isPlainObject(server.httpHeaders) ? server.httpHeaders : {}
const headerEntries = Object.entries(headers)
if (headerEntries.length) {
lines.push('', `[mcp_servers.${tomlKey(server.serverName)}.http_headers]`)
for (const [key, value] of headerEntries) {
lines.push(`${tomlKey(key)} = ${tomlString(value)}`)
}
}
return lines.join('\n')
}
function dynamicMcpConfig(servers) {
return (Array.isArray(servers) ? servers : [])
.map(dynamicMcpServerConfig)
.filter(Boolean)
.join('\n\n')
}
function ndcAgentMcpServerConfig(mcpContext, cwd) {
const ndcConfig = [
'[mcp_servers.ndc_agent_core]',
`command = ${tomlString('node')}`,
`args = [${tomlString(NDC_AGENT_MCP_SERVER)}]`,
'startup_timeout_sec = 20',
'tool_timeout_sec = 120',
].join('\n')
return `${ndcConfig}\n${ndcAgentMcpEnvConfig(mcpContext, cwd)}`
}
function dynamicCodexHomePath(payload, needsNdcAgentCore) {
const runProfile = runProfileFromPayload(payload)
const runId = cleanRunDirectoryName(runProfile.runId || payload?.requestId || payload?.threadId || '')
return path.join(RUN_CODEX_HOME_ROOT, needsNdcAgentCore ? `ndc-${runId}` : runId)
}
function stripTomlSection(raw, sectionName) {
const section = String(sectionName || '').trim()
if (!section) return String(raw || '')
const lines = String(raw || '').split(/\r?\n/)
const result = []
let skipping = false
for (const line of lines) {
const header = normalizedTomlHeader(line)
if (header) {
skipping = header === `[${section}]` || header.startsWith(`[${section}.`)
}
if (!skipping) result.push(line)
}
return result.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()
}
function stripTopLevelTomlKeys(raw, keyNames) {
const keys = new Set((Array.isArray(keyNames) ? keyNames : []).map((key) => String(key || '').trim()).filter(Boolean))
if (!keys.size) return String(raw || '')
const lines = String(raw || '').split(/\r?\n/)
const result = []
let inTable = false
for (const line of lines) {
if (normalizedTomlHeader(line)) {
inTable = true
result.push(line)
continue
}
if (!inTable) {
const match = stripTomlComment(line).match(/^\s*([A-Za-z0-9_-]+)\s*=/)
if (match && keys.has(match[1])) continue
}
result.push(line)
}
return result.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()
}
function extractTomlSection(raw, sectionName) {
const section = String(sectionName || '').trim()
if (!section) return ''
const headerKey = `[${section}]`
const lines = String(raw || '').split(/\r?\n/)
const result = []
let collecting = false
for (const line of lines) {
const header = normalizedTomlHeader(line)
if (header) {
if (header === headerKey) {
collecting = true
result.push(line)
continue
}
if (collecting) break
}
if (collecting) result.push(line)
}
return result.join('\n').trim()
}
function normalizeMcpServerSection(section, serverName) {
const lines = String(section || '').split(/\r?\n/)
const result = []
let hasEnabled = false
let hasRequired = false
for (const line of lines) {
const header = normalizedTomlHeader(line)
if (header) {
result.push(`[mcp_servers.${serverName}]`)
continue
}
const activeLine = stripTomlComment(line)
if (/^\s*transport\s*=/.test(activeLine)) continue
if (/^\s*required\s*=/.test(activeLine)) {
result.push('required = false')
hasRequired = true
continue
}
if (/^\s*enabled\s*=/.test(activeLine)) {
result.push('enabled = true')
hasEnabled = true
continue
}
result.push(line)
}
if (!hasEnabled) result.push('enabled = true')
if (!hasRequired) result.push('required = false')
return result.join('\n').trim()
}
function normalizeMcpHeadersSection(section, serverName) {
if (!section) return ''
const lines = String(section || '').split(/\r?\n/)
const result = []
for (const line of lines) {
const header = normalizedTomlHeader(line)
if (header) {
result.push(`[mcp_servers.${serverName}.http_headers]`)
continue
}
result.push(line)
}
return result.join('\n').trim()
}
function extractOptionalOpsMcpConfig(raw) {
const serverNames = ['nodedc-ops-agent', 'nodedc_tasker']
const configs = []
for (const serverName of serverNames) {
const baseSection = extractTomlSection(raw, `mcp_servers.${serverName}`)
if (!baseSection) continue
const headersSection = extractTomlSection(raw, `mcp_servers.${serverName}.http_headers`)
|| extractTomlSection(raw, `mcp_servers.${serverName}.headers`)
const normalized = [
normalizeMcpServerSection(baseSection, serverName),
normalizeMcpHeadersSection(headersSection, serverName),
].filter(Boolean).join('\n\n').trim()
if (normalized) configs.push(normalized)
}
return configs.join('\n\n').trim()
}
async function readOptionalOpsMcpConfig() {
try {
const raw = await fs.readFile(path.join(CODEX_HOME, 'config.toml'), 'utf8')
return extractOptionalOpsMcpConfig(raw)
} catch {
return ''
}
}
async function copyIfExists(from, to) {
try {
await fs.copyFile(from, to)
return true
} catch (error) {
if (error?.code === 'ENOENT') return false
throw error
}
}
function ndcAgentMcpEnvConfig(mcpContext, cwd) {
return [
'[mcp_servers.ndc_agent_core.env]',
`NDC_AGENT_MCP_ROOT = ${tomlString(CODEX_CWD)}`,
`NDC_AGENT_MCP_CONTEXT = ${tomlString(JSON.stringify(mcpContext || {}))}`,
`NDC_AGENT_MCP_API_BASE_URL = ${tomlString(mcpContext?.ndcAgentMcpApiBaseUrl || '')}`,
`AI_BRIDGE_HUB_URL = ${tomlString(hubState.url || HUB_URL)}`,
`AI_BRIDGE_HUB_URLS = ${tomlString(HUB_URLS.join(','))}`,
`AI_BRIDGE_PAIRING_CODE = ${tomlString(PAIRING_CODE)}`,
`AI_BRIDGE_CODEX_CWD = ${tomlString(cwd || CODEX_CWD)}`,
].join('\n')
}
async function prepareRunCodexHome({ payload = {}, cwd = CODEX_CWD, mcpContext = null, dynamicMcpServers = [] } = {}) {
const needsNdcAgentCore = isPlainObject(mcpContext)
const hasDynamicMcp = Array.isArray(dynamicMcpServers) && dynamicMcpServers.length > 0
const codexHome = hasDynamicMcp
? dynamicCodexHomePath(payload, needsNdcAgentCore)
: NDC_AGENT_CODEX_HOME
await fs.mkdir(codexHome, { recursive: true })
await copyIfExists(path.join(CODEX_HOME, 'auth.json'), path.join(codexHome, 'auth.json'))
const legacyOpsMcpConfig = needsNdcAgentCore && !hasDynamicMcp ? await readOptionalOpsMcpConfig() : ''
const config = [
runtimeCodexConfig(),
needsNdcAgentCore ? ndcAgentMcpServerConfig(mcpContext, cwd) : '',
hasDynamicMcp ? dynamicMcpConfig(dynamicMcpServers) : legacyOpsMcpConfig,
].filter(Boolean).join('\n\n')
await fs.writeFile(path.join(codexHome, 'config.toml'), `${config}\n`, 'utf8')
return codexHome
}
async function codexInvocationForPayload(baseArgs, payload, cwd) {
const dynamicMcpServers = runProfileMcpServers(payload)
const needsNdcAgentCore = isNdcAgentCorePayload(payload)
if (!needsNdcAgentCore && !dynamicMcpServers.length) return { args: baseArgs, env: {}, dynamicMcpServerNames: [] }
const mcpContext = needsNdcAgentCore ? buildNdcAgentMcpContext(payload, cwd) : null
const codexHome = await prepareRunCodexHome({ payload, cwd, mcpContext, dynamicMcpServers })
return {
args: baseArgs,
env: {
CODEX_HOME: codexHome,
...(needsNdcAgentCore ? {
NDC_AGENT_MCP_ROOT: CODEX_CWD,
NDC_AGENT_MCP_CONTEXT: JSON.stringify(mcpContext),
NDC_AGENT_MCP_API_BASE_URL: mcpContext.ndcAgentMcpApiBaseUrl,
AI_BRIDGE_PAIRING_CODE: PAIRING_CODE,
} : {}),
},
dynamicMcpServerNames: dynamicMcpServers.map((server) => server.serverName),
codexHome,
}
}
function isResumeRecoverableError(error) {
const text = String(error?.message || error || '')
return /unknown command|unrecognized|unexpected argument|invalid subcommand|usage:|failed to refresh available models|timeout waiting for child process to exit|codex_timeout|session.*not.*found|no such session|could not find.*session|session.*does not exist|no rollout found|thread\/resume.*failed|error resuming thread/i.test(text)
}
function usesJsonOutput(args) {
return Array.isArray(args) && args.includes('--json')
}
function truncateText(value, max = 1400) {
const text = String(value || '').trim()
return text.length > max ? `${text.slice(0, max).trim()}...` : text
}
function markdownText(value, max = 12000) {
return truncateText(String(value || '').replace(/\r\n/g, '\n'), max)
}
function sanitizeCodexErrorText(value, max = 1400) {
const text = String(value || '').replace(/\r\n/g, '\n').trim()
if (!text) return ''
const statusMatch = text.match(/\b(?:HTTP error:\s*)?((?:401|403|429|5\d\d)\s+[A-Za-z][A-Za-z\s-]*)\b/i)
const urlMatch = text.match(/\burl:\s*((?:https?|wss):\/\/[^\s,]+)/i)
|| text.match(/\b((?:https?|wss):\/\/chatgpt\.com\/[^\s,]+)/i)
const rayMatch = text.match(/\bcf-ray:\s*([A-Za-z0-9-]+)/i)
|| text.match(/\bRay ID:?\s*([A-Za-z0-9-]+)/i)
if (/chatgpt\.com\/backend-api\/codex|Unable to load site|cf-ray|Cloudflare|403 Forbidden/i.test(text)) {
return [
`Codex backend rejected request${statusMatch ? `: ${statusMatch[1].trim()}` : ''}.`,
urlMatch ? `url: ${urlMatch[1].replace(/[)>]+$/, '')}` : '',
rayMatch ? `cf-ray: ${rayMatch[1]}` : '',
].filter(Boolean).join(' ')
}
return truncateText(text, max)
}
function eventText(event) {
return [
event?.message,
event?.text,
event?.command ? `$ ${event.command}` : '',
].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
}
function renderMirrorSignal(label, value) {
const text = markdownText(value, 1800)
return text ? `- ${label}: ${text.replace(/\n/g, '\n ')}` : ''
}
function renderActiveMirrorMarkdown(state) {
const activity = state.events.length
? state.events.map((event) => {
const text = markdownText(event.text, 2200)
const inline = text.includes('\n') ? `\n\n${text}\n` : ` ${text}`
return `- ${event.at} ${event.kind}:${inline}`
}).join('\n')
: '- waiting for Codex events'
const signal = [
renderMirrorSignal('Reasoning', state.currentReasoning),
renderMirrorSignal('Command', state.currentCommand),
renderMirrorSignal('Files', state.currentFiles),
renderMirrorSignal('Tool', state.currentTool),
renderMirrorSignal('Todo', state.currentTodo),
].filter(Boolean).join('\n') || '- waiting for first Codex signal'
return [
'# NDC AI Workspace Active Run',
'',
`Status: ${state.status}`,
`Machine: ${state.machineName}`,
`Workspace: ${state.cwd}`,
`Mode: ${state.modeTitle || state.modeId || 'unknown'}`,
`Thread: ${state.threadTitle || state.threadId || 'unknown'}`,
`Request: ${state.requestId}`,
`Started: ${state.startedAt}`,
`Updated: ${state.updatedAt}`,
state.finishedAt ? `Finished: ${state.finishedAt}` : '',
'',
'## Active Mirror',
'',
`Current file: ${state.currentFile}`,
`Events file: ${state.eventsFile}`,
'',
'## User Message',
'',
markdownText(state.userMessage || ''),
'',
'## Current Signal',
'',
signal,
'',
'## Live Activity',
'',
activity,
'',
'## Last Assistant Output',
'',
markdownText(state.assistantText || 'No assistant output yet.'),
'',
].filter((line) => line !== '').join('\n')
}
function openActiveMirrorInIde(filePath) {
if (!ACTIVE_MIRROR_OPEN_IDE || !ACTIVE_MIRROR_IDE_BIN || !filePath) return
try {
const child = spawn(ACTIVE_MIRROR_IDE_BIN, ['-r', filePath], {
detached: true,
shell: process.platform === 'win32',
stdio: 'ignore',
windowsHide: true,
})
child.unref()
} catch {}
}
function activeMirrorPathsForCwd(cwd) {
const workspace = resolveWorkspacePath(cwd || CODEX_CWD)
const mirrorDir = workspace === CODEX_CWD
? ACTIVE_MIRROR_DIR
: path.join(workspace, '.nodedc', 'ai-workspace')
return {
mirrorDir,
currentFile: path.join(mirrorDir, 'current.md'),
eventsFile: path.join(mirrorDir, 'events.jsonl'),
}
}
async function hasGitMarker(dir) {
try {
await fs.stat(path.join(dir, '.git'))
return true
} catch {
return false
}
}
async function addWorkspace(map, workspacePath, source, patch = {}) {
const cleanPath = String(workspacePath || '').trim()
if (!cleanPath) return
let stat = null
try {
stat = await fs.stat(cleanPath)
} catch {
return
}
if (!stat.isDirectory()) return
const id = workspaceId(cleanPath)
const previous = map.get(id) || {}
map.set(id, {
id,
path: cleanPath,
title: patch.title || previous.title || workspaceTitle(cleanPath),
kind: patch.kind || previous.kind || (await hasGitMarker(cleanPath) ? 'git' : 'folder'),
source: Array.from(new Set([...(previous.source ? String(previous.source).split(', ') : []), source].filter(Boolean))).join(', '),
lastUsedAt: patch.lastUsedAt || previous.lastUsedAt || null,
sessionCount: Number(previous.sessionCount || 0) + Number(patch.sessionCount || 0),
})
}
async function discoverWorkspaces() {
const map = new Map()
await addWorkspace(map, CODEX_CWD, 'configured', { title: `${workspaceTitle(CODEX_CWD)} (default)` })
const conversations = await readConversations()
for (const conversation of conversations) {
if (!conversation.workspacePath) continue
await addWorkspace(map, conversation.workspacePath, 'codex-session', {
lastUsedAt: conversation.workspaceUpdatedAt || conversation.updatedAt || null,
sessionCount: 1,
})
}
const workspaces = Array.from(map.values())
.sort((a, b) => {
if (a.path === CODEX_CWD) return -1
if (b.path === CODEX_CWD) return 1
const byDate = String(b.lastUsedAt || '').localeCompare(String(a.lastUsedAt || ''))
if (byDate) return byDate
return String(a.title || '').localeCompare(String(b.title || ''))
})
.slice(0, MAX_WORKSPACES)
return {
ok: true,
cwd: CODEX_CWD,
workspaces,
}
}
function createActiveMirror(command, payload = {}, meta = {}) {
const context = payload?.context && typeof payload.context === 'object' ? payload.context : {}
if (!ACTIVE_MIRROR_ENABLED || command !== 'message' || context.remoteControlMode !== 'active') return null
const cwd = String(meta.cwd || CODEX_CWD)
const displayMessage = String(payload.displayMessage || payload.publicUserMessage || payload.message || '')
const { mirrorDir, currentFile, eventsFile } = activeMirrorPathsForCwd(cwd)
const startedAt = new Date().toISOString()
const state = {
requestId: String(meta.requestId || payload.threadId || `local-${Date.now()}`),
threadId: String(payload.threadId || ''),
threadTitle: String(payload.threadTitle || ''),
userMessage: displayMessage,
modeId: String(context.modeId || ''),
modeTitle: String(context.modeTitle || ''),
machineName: MACHINE_NAME,
cwd,
status: 'running',
startedAt,
updatedAt: startedAt,
finishedAt: '',
assistantText: '',
currentFile,
eventsFile,
currentReasoning: '',
currentCommand: '',
currentFiles: '',
currentTool: '',
currentTodo: '',
events: [],
ideOpened: false,
}
let chain = Promise.resolve()
const write = (row) => {
chain = chain.then(async () => {
state.updatedAt = new Date().toISOString()
await fs.mkdir(mirrorDir, { recursive: true })
if (row) {
await fs.appendFile(eventsFile, `${JSON.stringify({
requestId: state.requestId,
threadId: state.threadId,
threadTitle: state.threadTitle,
machineName: state.machineName,
cwd: state.cwd,
...row,
})}\n`, 'utf8')
}
await fs.writeFile(currentFile, renderActiveMirrorMarkdown(state), 'utf8')
if (!state.ideOpened) {
state.ideOpened = true
openActiveMirrorInIde(currentFile)
}
}).catch(() => {})
return chain
}
const record = (event = {}) => {
const kind = String(event.kind || event.type || 'event')
const text = eventText(event)
const row = {
at: new Date().toISOString(),
kind,
text: markdownText(text, 6000),
}
if (event.currentFile) row.currentFile = String(event.currentFile)
if (event.eventsFile) row.eventsFile = String(event.eventsFile)
if (kind === 'codex_reasoning') state.currentReasoning = markdownText(event.text || text, 6000)
if (kind === 'codex_command' || kind === 'start') state.currentCommand = markdownText(event.command || text, 3000)
if (kind === 'codex_files') state.currentFiles = markdownText(event.text || text, 3000)
if (kind === 'codex_tool') state.currentTool = markdownText(event.text || text, 3000)
if (kind === 'codex_todo') state.currentTodo = markdownText(event.text || text, 3000)
if (kind === 'codex_message') state.assistantText = markdownText(event.text || text, 12000)
if (kind === 'done' || kind === 'codex_turn_completed') state.status = 'completed'
if (kind === 'timeout') state.status = 'timeout'
if (kind === 'error' && !/reconnecting/i.test(text)) state.status = 'failed'
state.events.push(row)
state.events = state.events.slice(-ACTIVE_MIRROR_MAX_EVENTS)
write(row)
}
return {
currentFile,
eventsFile,
record,
async complete(status, assistantText = '') {
state.status = status
state.finishedAt = new Date().toISOString()
if (assistantText) state.assistantText = markdownText(assistantText, 12000)
await write({
at: state.finishedAt,
kind: `mirror_${status}`,
text: status,
})
},
}
}
function textFromUnknown(value) {
if (typeof value === 'string') return value
if (Array.isArray(value)) return value.map(textFromUnknown).filter(Boolean).join('\n')
if (value && typeof value === 'object') {
return [
value.text,
value.summary,
value.content,
value.message,
value.delta,
].map(textFromUnknown).filter(Boolean).join('\n')
}
return ''
}
function reasoningTextFromItem(item) {
return textFromUnknown([
item?.summary,
item?.summary_text,
item?.text,
item?.content,
])
}
function reasoningSummaryEventKey(event) {
const itemId = String(event?.item_id || event?.item?.id || event?.response_id || 'reasoning').trim() || 'reasoning'
const summaryIndex = String(event?.summary_index ?? event?.content_index ?? event?.output_index ?? 0)
return `${itemId}:${summaryIndex}`
}
function reasoningSummaryTextFromEvent(event) {
return textFromUnknown([
event?.text,
event?.delta,
event?.part,
event?.summary,
event?.summary_text,
])
}
function shouldEmitReasoningSummaryDelta(record, pending) {
const text = String(pending || '').trim()
if (!text) return false
if (/[.!?。!?]\s*$/.test(text)) return true
if (text.length >= REASONING_SUMMARY_DELTA_MIN_CHARS) return true
return nowMs() - Number(record.lastEmittedAt || 0) >= REASONING_SUMMARY_DELTA_MAX_WAIT_MS && text.length >= 32
}
function updateReasoningSummaryState(state, event, mode) {
if (!state) return null
const key = reasoningSummaryEventKey(event)
const record = state.get(key) || { text: '', emittedLength: 0, lastEmittedAt: 0 }
if (mode === 'delta') {
const delta = textFromUnknown(event?.delta)
if (!delta) return null
record.text += delta
const pending = record.text.slice(record.emittedLength).trim()
if (!shouldEmitReasoningSummaryDelta(record, pending)) {
state.set(key, record)
return null
}
record.emittedLength = record.text.length
record.lastEmittedAt = nowMs()
state.set(key, record)
return pending
}
const text = reasoningSummaryTextFromEvent(event)
if (text) record.text = text
const pending = record.text.slice(record.emittedLength).trim() || (!record.emittedLength ? record.text.trim() : '')
record.emittedLength = record.text.length
record.lastEmittedAt = nowMs()
state.set(key, record)
return pending
}
function createCodexJsonEventMapper() {
const reasoningSummaryState = new Map()
return (event) => codexJsonEventToBridgeEvent(event, reasoningSummaryState)
}
function summarizeFileChanges(changes) {
const list = Array.isArray(changes) ? changes : []
if (!list.length) return 'File changes reported.'
return list
.slice(0, 12)
.map((change) => `${String(change.kind || 'update')}: ${String(change.path || '')}`.trim())
.join('\n')
}
function codexJsonEventToBridgeEvent(event, reasoningSummaryState = null) {
const type = String(event?.type || '')
const item = event?.item && typeof event.item === 'object' ? event.item : null
const itemType = String(item?.type || '')
if (type === 'response.reasoning_summary_text.delta') {
const text = truncateText(updateReasoningSummaryState(reasoningSummaryState, event, 'delta'))
return text ? { kind: 'codex_reasoning', message: text } : null
}
if (type === 'response.reasoning_summary_text.done') {
const text = truncateText(updateReasoningSummaryState(reasoningSummaryState, event, 'done'))
return text ? { kind: 'codex_reasoning', message: text } : null
}
if (type === 'response.reasoning_summary_part.done' || type === 'response.reasoning_summary_part.added') {
const text = truncateText(reasoningSummaryTextFromEvent(event))
return text ? { kind: 'codex_reasoning', message: text } : null
}
if (type === 'response.reasoning_text.delta' || type === 'response.reasoning_text.done') return null
if (type === 'thread.started') {
const sessionId = String(event.thread_id || '').trim()
return { kind: 'codex_thread', sessionId, message: `Thread started: ${sessionId}`.trim() }
}
if (type === 'turn.started') return { kind: 'codex_turn', message: 'Turn started.' }
if (type === 'turn.completed') {
const usage = event.usage || {}
const tokens = Number(usage.input_tokens || 0) + Number(usage.output_tokens || 0)
return { kind: 'codex_turn_completed', message: tokens ? `Turn completed. Tokens: ${tokens}.` : 'Turn completed.' }
}
if (type === 'turn.failed') return { kind: 'error', message: event?.error?.message || 'Turn failed.' }
if (type === 'error') return { kind: 'error', message: event.message || 'Codex stream error.' }
if (!item) return null
const prefix = type === 'item.started' ? 'Started' : type === 'item.updated' ? 'Updated' : 'Completed'
if (itemType === 'reasoning') {
const text = truncateText(reasoningTextFromItem(item))
return text ? { kind: 'codex_reasoning', message: text } : null
}
if (itemType === 'agent_message') return { kind: 'codex_message', message: 'Assistant response received.', text: truncateText(item.text || '') }
if (itemType === 'command_execution') {
const status = item.status ? ` - ${item.status}` : ''
return {
kind: 'codex_command',
command: item.command || '',
message: `${prefix} command${status}: ${item.command || ''}`.trim(),
text: truncateText(item.aggregated_output || ''),
}
}
if (itemType === 'file_change') {
return {
kind: 'codex_files',
message: `${prefix} file change${item.status ? ` - ${item.status}` : ''}.`,
text: summarizeFileChanges(item.changes),
}
}
if (itemType === 'mcp_tool_call') {
return {
kind: 'codex_tool',
message: `${prefix} MCP ${item.server || ''}.${item.tool || ''}${item.status ? ` - ${item.status}` : ''}`.trim(),
text: truncateText(item.error?.message || ''),
}
}
if (itemType === 'todo_list') {
const todos = Array.isArray(item.items)
? item.items.map((todo) => `${todo.completed ? '[x]' : '[ ]'} ${todo.text || ''}`).join('\n')
: ''
return { kind: 'codex_todo', message: `${prefix} todo list.`, text: truncateText(todos) }
}
if (itemType === 'web_search') return { kind: 'codex_tool', message: `${prefix} web search: ${item.query || ''}`.trim() }
if (itemType === 'error') return { kind: 'error', message: item.message || 'Codex item error.' }
return { kind: 'codex_event', message: `${prefix} ${itemType || type}.` }
}
function finalMessageFromCodexEvent(event) {
const item = event?.item && typeof event.item === 'object' ? event.item : null
if (!item || item.type !== 'agent_message') return ''
return String(item.text || '').trim()
}
async function runCodexForPayload(prompt, payload, onEvent = () => {}, cwd = CODEX_CWD, meta = {}) {
const baseArgs = payload?.resume ? buildResumeArgs(payload?.threadId) : CODEX_ARGS
const invocation = await codexInvocationForPayload(baseArgs, payload, cwd)
if (isNdcAgentCorePayload(payload)) {
try {
const context = buildNdcAgentMcpContext(payload, cwd)
onEvent({
kind: 'ndc_agent_mcp_context',
message: `NDC Agent MCP API base: ${context.ndcAgentMcpApiBaseUrl}`,
})
} catch {}
}
if (Array.isArray(invocation.dynamicMcpServerNames) && invocation.dynamicMcpServerNames.length) {
onEvent({
kind: 'run_profile',
message: `Dynamic run profile MCP servers: ${invocation.dynamicMcpServerNames.join(', ')}`,
})
}
const control = {
requestId: meta?.requestId,
threadId: payload?.threadId,
}
if (baseArgs === CODEX_ARGS) {
return runCodex(prompt, invocation.args, onEvent, MESSAGE_TIMEOUT_MS, cwd, control, invocation.env)
}
try {
return await runCodex(prompt, invocation.args, onEvent, RESUME_TIMEOUT_MS, cwd, control, invocation.env)
} catch (error) {
if (!isResumeRecoverableError(error)) throw error
onEvent({ kind: 'fallback', message: 'Codex resume failed; falling back to a new exec run.' })
const fallback = await codexInvocationForPayload(CODEX_ARGS, payload, cwd)
return runCodex(prompt, fallback.args, onEvent, MESSAGE_TIMEOUT_MS, cwd, control, fallback.env)
}
}
function runCodex(prompt, args = CODEX_ARGS, onEvent = () => {}, timeoutMs = MESSAGE_TIMEOUT_MS, cwd = CODEX_CWD, control = {}, envOverrides = {}) {
return new Promise((resolve, reject) => {
const startedAt = nowMs()
let firstOutputSeen = false
const jsonOutput = usesJsonOutput(args)
let jsonBuffer = ''
let finalJsonMessage = ''
let plainStdout = ''
const mapCodexJsonEvent = createCodexJsonEventMapper()
onEvent({
kind: 'start',
command: `${CODEX_BIN} ${args.join(' ')}`,
cwd,
message: 'Codex process started.',
})
const child = spawn(CODEX_BIN, args, {
cwd,
env: { ...process.env, ...(envOverrides || {}) },
shell: process.platform === 'win32',
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
})
const activeRun = registerActiveCodexRun(control, child, onEvent)
let stdout = ''
let stderr = ''
let killed = false
const timeout = setTimeout(() => {
killed = true
onEvent({ kind: 'timeout', message: 'Codex process timed out.' })
child.kill('SIGTERM')
}, timeoutMs)
child.stdout.on('data', (chunk) => {
const text = String(chunk)
if (!firstOutputSeen) {
firstOutputSeen = true
onEvent({ kind: 'first_output', message: `First Codex output after ${elapsedLabel(startedAt)}.` })
}
stdout += text
if (jsonOutput) {
jsonBuffer += text
const lines = jsonBuffer.split(/\r?\n/)
jsonBuffer = lines.pop() || ''
lines.forEach((line) => {
const trimmed = line.trim()
if (!trimmed) return
try {
const event = JSON.parse(trimmed)
const finalMessage = finalMessageFromCodexEvent(event)
if (finalMessage) finalJsonMessage = finalMessage
const bridgeEvent = mapCodexJsonEvent(event)
if (bridgeEvent) onEvent(bridgeEvent)
} catch {
plainStdout += `${line}\n`
onEvent({ kind: 'stdout', stream: 'stdout', text: line })
}
})
} else {
plainStdout += text
onEvent({ kind: 'stdout', stream: 'stdout', text })
}
if (Buffer.byteLength(stdout) > MAX_STDIO_BYTES) child.kill('SIGTERM')
})
child.stderr.on('data', (chunk) => {
const text = String(chunk)
if (!firstOutputSeen) {
firstOutputSeen = true
onEvent({ kind: 'first_output', message: `First Codex output after ${elapsedLabel(startedAt)}.` })
}
stderr += text
onEvent({ kind: 'stderr', stream: 'stderr', text: sanitizeCodexErrorText(text) })
if (Buffer.byteLength(stderr) > MAX_STDIO_BYTES) child.kill('SIGTERM')
})
child.on('error', (error) => {
clearTimeout(timeout)
activeRun?.unregister()
onEvent({ kind: 'error', message: String(error?.message || error || 'codex_error') })
reject(error)
})
child.on('close', (code) => {
clearTimeout(timeout)
activeRun?.unregister()
if (activeRun?.stopRequested) {
onEvent({ kind: 'stopped', message: 'Codex process stopped.' })
return resolve('')
}
const stderrMessage = sanitizeCodexErrorText(stderr.trim())
if (killed) return reject(new Error(`codex_timeout${stderrMessage ? `: ${stderrMessage.slice(0, 1000)}` : ''}`))
if (code !== 0) {
onEvent({ kind: 'error', message: stderrMessage || `codex_exit_${code}` })
return reject(new Error(stderrMessage || `codex_exit_${code}`))
}
onEvent({ kind: 'done', message: `Codex process completed in ${elapsedLabel(startedAt)}.` })
resolve(finalJsonMessage || plainStdout.trim() || stdout.trim() || stderr.trim())
})
child.stdin.end(prompt)
})
}
function stripTomlComment(line) {
const text = String(line || '')
let inSingle = false
let inDouble = false
let escaped = false
for (let i = 0; i < text.length; i += 1) {
const char = text[i]
if (escaped) {
escaped = false
continue
}
if (inDouble && char === '\\') {
escaped = true
continue
}
if (!inDouble && char === "'") {
inSingle = !inSingle
continue
}
if (!inSingle && char === '"') {
inDouble = !inDouble
continue
}
if (!inSingle && !inDouble && char === '#') {
return text.slice(0, i)
}
}
return text
}
function normalizedTomlKey(line) {
return stripTomlComment(line).replace(/\s+/g, '').replace(/["']/g, '')
}
function normalizedTomlHeader(line) {
const text = stripTomlComment(line).trim()
if (!text.startsWith('[') || !text.endsWith(']')) return ''
return normalizedTomlKey(text)
}
function hasOpsAgentInlineTransport(line) {
const text = normalizedTomlKey(line)
return /^nodedc-ops-agent=\{.*(?:^|[,}])?transport=/.test(text)
}
async function inspectCodexRuntime() {
const configPath = path.join(CODEX_HOME, 'config.toml')
const authPath = path.join(CODEX_HOME, 'auth.json')
try {
await fs.access(authPath)
} catch {
return {
ok: false,
error: 'codex_auth_missing',
configPath,
authPath,
}
}
try {
const raw = await fs.readFile(configPath, 'utf8')
let inMcpServers = false
let inOpsAgent = false
for (const line of raw.split(/\r?\n/)) {
const header = normalizedTomlHeader(line)
if (header) {
if (header === '[mcp_servers.nodedc-ops-agent.headers]') {
return {
ok: false,
error: 'invalid_codex_mcp_headers:nodedc-ops-agent',
configPath,
authPath,
}
}
inMcpServers = header === '[mcp_servers]'
inOpsAgent = header === '[mcp_servers.nodedc-ops-agent]'
continue
}
const activeLine = stripTomlComment(line)
if (
(inOpsAgent && /^\s*transport\s*=/.test(activeLine)) ||
(inMcpServers && hasOpsAgentInlineTransport(activeLine))
) {
return {
ok: false,
error: 'invalid_codex_mcp_transport:nodedc-ops-agent',
configPath,
authPath,
}
}
}
} catch (error) {
if (error?.code !== 'ENOENT') {
return {
ok: false,
error: `codex_config_read_failed:${String(error?.message || error)}`,
configPath,
authPath,
}
}
}
return { ok: true, error: '', configPath, authPath }
}
async function handleBridgeCommand(command, payload = {}, onEvent = () => {}, meta = {}) {
if (command === 'health') {
const runtime = await inspectCodexRuntime()
return {
ok: runtime.ok,
error: runtime.error,
service: 'ai-workspace-bridge',
host: os.hostname(),
machineName: MACHINE_NAME,
runtimeStatus: runtime.ok ? 'ready' : 'error',
runtimeError: runtime.error,
runtimeCheckedAt: new Date().toISOString(),
codexHome: CODEX_HOME,
codexAuthPath: runtime.authPath,
codexBin: CODEX_BIN,
codexArgs: CODEX_ARGS,
dynamicRunProfile: {
enabled: true,
codexHomeRoot: RUN_CODEX_HOME_ROOT,
},
cwd: CODEX_CWD,
hubMode: hubState.mode,
hubConnected: hubState.connected,
hub: { ...hubState },
activeMirror: {
enabled: ACTIVE_MIRROR_ENABLED,
currentFile: ACTIVE_MIRROR_CURRENT_FILE,
eventsFile: ACTIVE_MIRROR_EVENTS_FILE,
},
}
}
if (command === 'conversations') {
const conversations = await readConversations()
return { ok: true, conversations }
}
if (command === 'workspaces') {
return discoverWorkspaces()
}
if (command === 'stop') {
return stopActiveCodexRun(payload)
}
if (command === 'message') {
const message = String(payload?.message || '').trim()
if (!message) {
const error = new Error('message_empty')
error.status = 400
throw error
}
const runtime = await inspectCodexRuntime()
if (!runtime.ok) {
const error = new Error(runtime.error || 'codex_runtime_not_ready')
error.status = 503
throw error
}
const cwd = await resolveExistingDirectory(payload?.workspacePath || payload?.context?.workspacePath || CODEX_CWD)
const messagePayload = {
...payload,
workspacePath: cwd,
context: {
...(payload?.context && typeof payload.context === 'object' ? payload.context : {}),
workspacePath: cwd,
workspaceTitle: workspaceTitle(cwd),
},
}
const mirror = createActiveMirror(command, messagePayload, { ...meta, cwd })
const emitEvent = (event) => {
mirror?.record(event)
onEvent(event)
}
if (mirror) {
emitEvent({
kind: 'active_mirror',
message: `Active mirror ready: ${mirror.currentFile}`,
currentFile: mirror.currentFile,
eventsFile: mirror.eventsFile,
cwd,
})
}
try {
const reply = await runCodexForPayload(buildPrompt(messagePayload), messagePayload, emitEvent, cwd, meta)
await mirror?.complete('completed', reply)
return {
ok: true,
threadId: String(payload.threadId || ''),
workspacePath: cwd,
workspaceTitle: workspaceTitle(cwd),
activeMirror: mirror ? {
currentFile: mirror.currentFile,
eventsFile: mirror.eventsFile,
} : null,
message: {
role: 'assistant',
text: reply,
createdAt: new Date().toISOString(),
},
}
} catch (error) {
await mirror?.complete('failed', String(error?.message || error || 'codex_failed'))
throw error
}
}
const error = new Error('command_unknown')
error.status = 404
throw error
}
async function dataToText(data) {
if (typeof data === 'string') return data
if (Buffer.isBuffer(data)) return data.toString('utf8')
if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
if (data?.arrayBuffer) return Buffer.from(await data.arrayBuffer()).toString('utf8')
return String(data || '')
}
function buildHubSocketUrl(hubUrl) {
const socketUrl = new URL(hubUrl)
socketUrl.searchParams.set('pairingCode', PAIRING_CODE)
socketUrl.searchParams.set('machineName', MACHINE_NAME)
return socketUrl.toString()
}
function attachHubSocket(socket, hubUrl) {
console.log(`[ai-workspace-bridge] hub connected: ${hubUrl}`)
hubState.connected = true
hubState.url = hubUrl
hubState.lastConnectedAt = new Date().toISOString()
hubState.lastError = ''
void inspectCodexRuntime().then((runtime) => {
try {
socket.send(JSON.stringify({
type: 'hello',
machineName: MACHINE_NAME,
host: os.hostname(),
cwd: CODEX_CWD,
runtimeStatus: runtime.ok ? 'ready' : 'error',
runtimeError: runtime.error,
runtimeCheckedAt: new Date().toISOString(),
sentAt: new Date().toISOString(),
}))
} catch {}
}).catch((error) => {
try {
socket.send(JSON.stringify({
type: 'hello',
machineName: MACHINE_NAME,
host: os.hostname(),
cwd: CODEX_CWD,
runtimeStatus: 'error',
runtimeError: `codex_runtime_check_failed:${String(error?.message || error)}`,
runtimeCheckedAt: new Date().toISOString(),
sentAt: new Date().toISOString(),
}))
} catch {}
})
socket.onmessage = async (event) => {
let request = null
try {
request = JSON.parse(await dataToText(event.data))
} catch {
return
}
if (request?.type !== 'request') return
const requestId = String(request.requestId || '')
const emitEvent = (payload) => {
try {
socket.send(JSON.stringify({
type: 'event',
requestId,
event: {
...(payload || {}),
at: new Date().toISOString(),
},
}))
} catch {}
}
try {
const receivedAfter = elapsedFromIso(request?.payload?.client?.sentAt)
emitEvent({
kind: 'bridge_received',
message: receivedAfter
? `Bridge received request after ${receivedAfter}.`
: 'Bridge received request.',
})
const payload = await handleBridgeCommand(String(request.command || ''), request.payload || {}, emitEvent, { requestId })
socket.send(JSON.stringify({ type: 'response', requestId, ok: true, payload }))
} catch (error) {
emitEvent({ kind: 'error', message: String(error?.message || error || 'bridge_agent_failed') })
socket.send(JSON.stringify({
type: 'response',
requestId,
ok: false,
status: Number(error?.status || 502),
error: String(error?.message || error || 'bridge_agent_failed'),
}))
}
}
socket.onerror = (event) => {
const message = String(event?.message || 'websocket_error')
hubState.lastError = message
console.error(`[ai-workspace-bridge] hub error: ${message} (${hubUrl})`)
}
socket.onclose = () => {
hubState.connected = false
hubState.lastDisconnectedAt = new Date().toISOString()
hubState.lastError = 'hub_disconnected'
console.error(`[ai-workspace-bridge] hub disconnected; reconnecting in ${HUB_RECONNECT_MS}ms (${hubUrl})`)
setTimeout(() => connectHub(), HUB_RECONNECT_MS)
}
}
function connectHub() {
if (!HUB_URLS.length || !PAIRING_CODE) return
if (typeof WebSocket === 'undefined') {
hubState.lastError = 'websocket_runtime_unavailable'
console.error('[ai-workspace-bridge] WebSocket is not available in this Node.js runtime; use Node.js 22+ for hub mode.')
return
}
const attempts = []
let reconnectScheduled = false
let selected = false
const scheduleReconnect = (delayMs, message) => {
if (reconnectScheduled) return
reconnectScheduled = true
if (message) {
hubState.lastError = message
console.error(message)
}
for (const attempt of attempts) {
try { attempt.socket.close() } catch {}
}
setTimeout(() => connectHub(), delayMs)
}
const openTimer = setTimeout(() => {
if (selected) return
scheduleReconnect(1000, `[ai-workspace-bridge] hub candidates unavailable after ${HUB_CONNECT_TIMEOUT_MS}ms; retrying.`)
}, HUB_CONNECT_TIMEOUT_MS)
for (const hubUrl of HUB_URLS) {
let socketUrl = ''
try {
socketUrl = buildHubSocketUrl(hubUrl)
} catch (error) {
const message = `invalid_hub_url:${String(error?.message || error)}`
hubState.lastError = message
console.error(`[ai-workspace-bridge] invalid hub URL: ${String(error?.message || error)} (${hubUrl})`)
continue
}
const socket = new WebSocket(socketUrl)
const attempt = { socket, hubUrl, closed: false }
attempts.push(attempt)
socket.onopen = () => {
if (selected) {
try { socket.close() } catch {}
return
}
selected = true
clearTimeout(openTimer)
for (const other of attempts) {
if (other.socket !== socket) {
try { other.socket.close() } catch {}
}
}
attachHubSocket(socket, hubUrl)
}
socket.onerror = (event) => {
if (!selected) {
const message = String(event?.message || 'websocket_error')
hubState.lastError = message
console.error(`[ai-workspace-bridge] hub error: ${message} (${hubUrl})`)
}
}
socket.onclose = () => {
attempt.closed = true
if (selected || reconnectScheduled) return
if (attempts.length && attempts.every((item) => item.closed)) {
clearTimeout(openTimer)
scheduleReconnect(1000, '[ai-workspace-bridge] all hub candidates closed before ready; retrying.')
}
}
}
if (!attempts.length) {
clearTimeout(openTimer)
scheduleReconnect(5000, '[ai-workspace-bridge] no valid hub candidates; retrying.')
}
}
async function handle(req, res) {
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`)
if (req.method === 'OPTIONS') return sendJson(res, 204, {})
if (req.method === 'GET' && url.pathname === '/api/ai-workspace/bridge/v1/health') {
return sendJson(res, 200, await handleBridgeCommand('health'))
}
if (req.method === 'GET' && url.pathname === '/api/ai-workspace/bridge/v1/conversations') {
return sendJson(res, 200, await handleBridgeCommand('conversations'))
}
if (req.method === 'GET' && url.pathname === '/api/ai-workspace/bridge/v1/workspaces') {
return sendJson(res, 200, await handleBridgeCommand('workspaces'))
}
if (req.method === 'POST' && url.pathname === '/api/ai-workspace/bridge/v1/message') {
const payload = await readBody(req)
try {
return sendJson(res, 200, await handleBridgeCommand('message', payload, () => {}, { requestId: `local-${Date.now()}` }))
} catch (error) {
return sendJson(res, Number(error?.status || 502), { ok: false, error: String(error?.message || error || 'codex_failed') })
}
}
if (req.method === 'POST' && url.pathname === '/api/ai-workspace/bridge/v1/stop') {
const payload = await readBody(req)
return sendJson(res, 200, await handleBridgeCommand('stop', payload))
}
return sendJson(res, 404, { ok: false, error: 'not_found' })
}
const server = http.createServer((req, res) => {
handle(req, res).catch((error) => {
sendJson(res, 500, { ok: false, error: String(error?.message || error || 'worker_failed') })
})
})
server.listen(PORT, HOST, () => {
console.log(`[ai-workspace-bridge] http://${HOST}:${PORT}`)
console.log(`[ai-workspace-bridge] codex: ${CODEX_BIN} ${CODEX_ARGS.join(' ')}`)
console.log(`[ai-workspace-bridge] codex home: ${CODEX_HOME}`)
console.log(`[ai-workspace-bridge] cwd: ${CODEX_CWD}`)
if (HUB_URLS.length && PAIRING_CODE) console.log(`[ai-workspace-bridge] hub candidates: ${HUB_URLS.join(', ')}`)
})
connectHub()
'@
$ndcAgentMcpServerCode = @'
#!/usr/bin/env node
import fs from 'node:fs/promises'
import path from 'node:path'
const ROOT = process.env.NDC_AGENT_MCP_ROOT || process.cwd()
const DEFAULT_API_BASE = 'http://127.0.0.1:3001/api/ndc-agent-mcp'
const DEFAULT_SCHEMA_VERSION = 'v2.3.2'
const ENGINE_FETCH_TIMEOUT_MS = Number(process.env.NDC_AGENT_MCP_FETCH_TIMEOUT_MS || 8000)
const context = parseContext()
const nodeCatalogCache = new Map()
let transportMode = null
let inputBuffer = Buffer.alloc(0)
const FALLBACK_NODE_CATALOG = [
{
name: 'webhook',
displayName: 'Webhook',
description: 'Starts a workflow from an HTTP webhook request.',
group: ['trigger'],
version: [2.1],
defaults: { name: 'Webhook' },
inputs: [],
outputs: ['main'],
properties: [
{ name: 'httpMethod', displayName: 'HTTP Method', type: 'options', default: 'POST' },
{ name: 'path', displayName: 'Path', type: 'string', default: '' },
{ name: 'responseMode', displayName: 'Response Mode', type: 'options', default: 'onReceived' },
],
},
{
name: 'set',
displayName: 'Set',
description: 'Sets or edits item fields.',
group: ['transform'],
version: [3.4],
defaults: { name: 'Set' },
inputs: ['main'],
outputs: ['main'],
properties: [
{ name: 'assignments', displayName: 'Assignments', type: 'fixedCollection', default: {} },
{ name: 'options', displayName: 'Options', type: 'collection', default: {} },
],
},
{
name: 'code',
displayName: 'Code',
description: 'Runs custom JavaScript code.',
group: ['transform'],
version: [2],
defaults: { name: 'Code' },
inputs: ['main'],
outputs: ['main'],
properties: [
{ name: 'mode', displayName: 'Mode', type: 'options', default: 'runOnceForAllItems' },
{ name: 'jsCode', displayName: 'JavaScript Code', type: 'string', default: 'return items;' },
],
},
{
name: 'httpRequest',
displayName: 'HTTP Request',
description: 'Makes an HTTP request and returns the response.',
group: ['input'],
version: [4.2],
defaults: { name: 'HTTP Request' },
inputs: ['main'],
outputs: ['main'],
properties: [
{ name: 'method', displayName: 'Method', type: 'options', default: 'GET' },
{ name: 'url', displayName: 'URL', type: 'string', default: '' },
{ name: 'sendHeaders', displayName: 'Send Headers', type: 'boolean', default: false },
{ name: 'sendBody', displayName: 'Send Body', type: 'boolean', default: false },
],
},
{
name: 'respondToWebhook',
displayName: 'Respond to Webhook',
description: 'Returns a response to a Webhook trigger.',
group: ['output'],
version: [1.1],
defaults: { name: 'Respond to Webhook' },
inputs: ['main'],
outputs: ['main'],
properties: [
{ name: 'respondWith', displayName: 'Respond With', type: 'options', default: 'json' },
{ name: 'responseBody', displayName: 'Response Body', type: 'json', default: '{}' },
],
},
{
name: 'noOp',
displayName: 'No Operation, do nothing',
description: 'Passes data through without changing it.',
group: ['transform'],
version: [1],
defaults: { name: 'No Operation, do nothing' },
inputs: ['main'],
outputs: ['main'],
properties: [],
},
]
function parseContext() {
const raw = String(process.env.NDC_AGENT_MCP_CONTEXT || '').trim()
let parsed = {}
if (raw) {
try { parsed = JSON.parse(raw) || {} } catch {}
}
return {
modeId: 'ndc-agent-core',
workflowId: cleanString(parsed.workflowId || process.env.NDC_AGENT_MCP_WORKFLOW_ID || ''),
workflowTitle: cleanString(parsed.workflowTitle || process.env.NDC_AGENT_MCP_WORKFLOW_TITLE || ''),
agentNodeId: cleanString(parsed.agentNodeId || process.env.NDC_AGENT_MCP_NODE_ID || ''),
agentNodeTitle: cleanString(parsed.agentNodeTitle || process.env.NDC_AGENT_MCP_NODE_TITLE || ''),
roleId: cleanString(parsed.roleId || process.env.NDC_AGENT_MCP_ROLE_ID || ''),
roleTitle: cleanString(parsed.roleTitle || process.env.NDC_AGENT_MCP_ROLE_TITLE || ''),
apiBaseUrl: cleanApiBase(parsed.ndcAgentMcpApiBaseUrl || process.env.NDC_AGENT_MCP_API_BASE_URL || DEFAULT_API_BASE),
schemaVersion: cleanString(parsed.schemaVersion || process.env.NDC_AGENT_MCP_SCHEMA_VERSION || DEFAULT_SCHEMA_VERSION),
n8nMcpRefPath: cleanString(parsed.n8nMcpRefPath || process.env.NDC_AGENT_MCP_REF_PATH || path.resolve(ROOT, '..', 'tools', 'NDCMCP')),
}
}
function cleanString(value, max = 1000) {
return String(value || '').trim().slice(0, max)
}
function cleanApiBase(value) {
const raw = cleanString(value || DEFAULT_API_BASE)
return raw.replace(/\/+$/, '') || DEFAULT_API_BASE
}
function uniqueStrings(values) {
const result = []
const seen = new Set()
for (const value of values || []) {
const clean = cleanString(value, 1000).replace(/\/+$/, '')
if (!clean || seen.has(clean)) continue
seen.add(clean)
result.push(clean)
}
return result
}
function isLoopbackUrl(value) {
try {
const host = new URL(value).hostname.toLowerCase()
return host === 'localhost' || host === '0.0.0.0' || host === '::1' || host.startsWith('127.')
} catch {
return false
}
}
function hubOriginToApiBase(value) {
try {
const url = new URL(cleanString(value, 1000))
const host = url.hostname.toLowerCase()
if (!host) return ''
if (url.protocol === 'wss:') url.protocol = 'https:'
else if (url.protocol === 'ws:') url.protocol = 'http:'
else if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''
url.pathname = ''
url.search = ''
url.hash = ''
const origin = url.toString().replace(/\/+$/, '')
if (host === 'ai-hub.nodedc.ru') {
const pairingCode = cleanPairingCode(process.env.AI_BRIDGE_PAIRING_CODE)
return pairingCode ? `${origin}/api/ai-workspace/hub/v1/ndc-agent-mcp/${pairingCode}` : ''
}
return `${origin}/api/ndc-agent-mcp`
} catch {
return ''
}
}
function cleanPairingCode(value) {
return cleanString(value, 100).toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 32)
}
function splitList(value) {
return cleanString(value, 4000).split(/[,;\r\n]+/).map((item) => item.trim()).filter(Boolean)
}
function engineApiBaseCandidates() {
const explicit = cleanApiBase(context.apiBaseUrl)
const hubBases = [
hubOriginToApiBase(process.env.AI_BRIDGE_HUB_URL),
...splitList(process.env.AI_BRIDGE_HUB_URLS).map(hubOriginToApiBase),
]
const envBase = cleanString(process.env.NDC_AGENT_MCP_API_BASE_URL, 1000).replace(/\/+$/, '')
if (explicit && !isLoopbackUrl(explicit)) {
return uniqueStrings([explicit, envBase])
}
if (envBase && !isLoopbackUrl(envBase)) {
return uniqueStrings([envBase, explicit])
}
if (hubBases.some(Boolean)) {
return uniqueStrings([...hubBases, envBase, explicit])
}
return uniqueStrings([explicit, envBase, DEFAULT_API_BASE])
}
function engineApiUrls(pathname) {
const pathValue = String(pathname || '')
return engineApiBaseCandidates().map((baseValue) => {
try {
const base = new URL(baseValue)
if (pathValue.startsWith('/api/')) return `${base.origin}${pathValue}`
if (pathValue.startsWith('/')) return `${baseValue}${pathValue}`
return new URL(pathValue, `${baseValue}/`).toString()
} catch {
return ''
}
}).filter(Boolean)
}
function fetchFailureText(error) {
const parts = [
error?.name,
error?.message,
error?.cause?.code,
error?.cause?.message,
].map((item) => cleanString(item, 300)).filter(Boolean)
return parts.join(':') || 'fetch_failed'
}
function toolText(value) {
const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
return { content: [{ type: 'text', text }], structuredContent: typeof value === 'string' ? undefined : value }
}
function jsonRpcResult(id, result) {
return { jsonrpc: '2.0', id, result }
}
function jsonRpcError(id, code, message, data) {
return { jsonrpc: '2.0', id, error: { code, message: String(message || 'error'), ...(data ? { data } : {}) } }
}
function writeMessage(message) {
const json = JSON.stringify(message)
if (transportMode === 'content-length') {
const body = Buffer.from(json, 'utf8')
process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`)
process.stdout.write(body)
return
}
process.stdout.write(`${json}\n`)
}
async function readJson(file, fallback = null) {
try { return JSON.parse(await fs.readFile(file, 'utf8')) } catch { return fallback }
}
async function loadNodeCatalog(version = context.schemaVersion) {
const key = cleanString(version || DEFAULT_SCHEMA_VERSION)
if (nodeCatalogCache.has(key)) return nodeCatalogCache.get(key)
const remoteCatalog = await loadRemoteNodeCatalog(key)
if (remoteCatalog.length) {
nodeCatalogCache.set(key, remoteCatalog)
return remoteCatalog
}
const direct = path.resolve(ROOT, 'server', 'assets', 'n8n', 'schema', key, 'nodes.catalog.json')
const fallback = path.resolve(ROOT, 'server', 'assets', 'n8n', 'schema', DEFAULT_SCHEMA_VERSION, 'nodes.catalog.json')
const raw = await readJson(direct, null) || await readJson(fallback, [])
const catalog = Array.isArray(raw) && raw.length ? raw : FALLBACK_NODE_CATALOG
nodeCatalogCache.set(key, catalog)
return catalog
}
async function loadN8nMcpVersion() {
const pkg = await readJson(path.join(context.n8nMcpRefPath, 'package.json'), null)
return pkg?.version ? String(pkg.version) : '2.33.2'
}
function engineApiUrl(pathname) {
return engineApiUrls(pathname)[0] || ''
}
async function loadRemoteNodeCatalog(version) {
const query = new URLSearchParams({ kind: 'nodes', version: version || DEFAULT_SCHEMA_VERSION })
const urls = engineApiUrls(`/api/n8n/schema?${query.toString()}`)
for (const url of urls) {
try {
const res = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(ENGINE_FETCH_TIMEOUT_MS) })
if (!res.ok) continue
const json = await res.json()
if (Array.isArray(json?.nodes)) return json.nodes
} catch {}
}
return []
}
function normalizeTypeName(value) {
const raw = cleanString(value, 240)
if (!raw) return ''
if (raw.startsWith('n8n-nodes-base.')) return raw.slice('n8n-nodes-base.'.length)
return raw
}
function fullNodeType(value) {
const raw = cleanString(value, 240)
if (!raw) return ''
return raw.startsWith('n8n-nodes-base.') ? raw : `n8n-nodes-base.${raw}`
}
function cleanWebhookSegment(value, fallback) {
const out = cleanString(value, 240)
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
return out || fallback
}
function makeNdcWebhookPath(workflowId, nodeId) {
return `ndc/${cleanWebhookSegment(workflowId, 'workflow')}/${cleanWebhookSegment(nodeId, 'agent')}/run`
}
function defaultSubworkflowGraph(workflowId = context.workflowId, nodeId = context.agentNodeId) {
return {
version: 1,
nodes: [{
id: 'webhook-trigger',
type: 'n8nOp',
position: { x: 0, y: 0 },
data: {
title: 'Webhook Trigger',
n8n: {
parameters: {
httpMethod: 'POST',
path: makeNdcWebhookPath(workflowId, nodeId),
responseMode: 'onReceived',
options: {},
},
type: 'n8n-nodes-base.webhook',
typeVersion: 2.1,
position: [0, 0],
id: 'webhook-trigger',
name: 'Webhook Trigger',
},
w: 220,
h: 120,
n8nTypeLabel: 'Webhook',
n8nShowId: false,
n8nOutputLabels: [],
},
}],
edges: [],
source: {
createdBy: 'ndc-agent-mcp',
initialized: 'default-webhook',
},
}
}
function normalizeGraphResult(value, workflowId = context.workflowId, nodeId = context.agentNodeId) {
const graph = value && typeof value === 'object' ? { ...value } : defaultSubworkflowGraph(workflowId, nodeId)
graph.version = Number(graph.version || 1) || 1
graph.nodes = Array.isArray(graph.nodes) && graph.nodes.length ? graph.nodes : defaultSubworkflowGraph(workflowId, nodeId).nodes
graph.edges = Array.isArray(graph.edges) ? graph.edges : []
return graph
}
function versionSummary(version) {
if (Array.isArray(version)) return version
if (version === undefined || version === null) return []
return [version]
}
function compactNodeDefinition(node, maxProperties = 80) {
const properties = Array.isArray(node?.properties) ? node.properties.slice(0, maxProperties) : []
return {
name: node?.name || '',
fullType: fullNodeType(node?.name || ''),
displayName: node?.displayName || node?.name || '',
description: node?.description || '',
group: node?.group || [],
versions: versionSummary(node?.version),
defaults: node?.defaults || {},
inputs: node?.inputs || [],
outputs: node?.outputs || [],
credentials: node?.credentials || [],
properties,
propertiesTruncated: Array.isArray(node?.properties) && node.properties.length > properties.length,
}
}
async function engineFetch(pathname, options = {}) {
const urls = engineApiUrls(pathname)
const failures = []
for (const url of urls) {
let res = null
try {
res = await fetch(url, {
...options,
redirect: 'manual',
signal: options.signal || AbortSignal.timeout(ENGINE_FETCH_TIMEOUT_MS),
headers: {
Accept: 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(options.headers || {}),
},
})
} catch (error) {
failures.push(`${url} -> ${fetchFailureText(error)}`)
continue
}
const text = await res.text().catch(() => '')
let json = null
try { json = text ? JSON.parse(text) : null } catch {}
if (!res.ok || json?.ok === false) {
const error = new Error(json?.message || json?.error || `engine_http_${res.status}`)
error.status = res.status
error.payload = { ...(json && typeof json === 'object' ? json : {}), url }
throw error
}
if (!json || typeof json !== 'object') {
const error = new Error(`engine_non_json_response:${res.status}`)
error.status = 502
error.payload = {
url,
status: res.status,
contentType: res.headers.get('content-type') || '',
preview: cleanString(text, 200),
}
throw error
}
return json
}
const error = new Error(`engine_fetch_failed:${failures.join(' | ') || 'no_engine_api_urls'}`)
error.payload = { attempts: failures, apiBaseCandidates: engineApiBaseCandidates(), pathname }
throw error
}
function targetFromArgs(args = {}) {
const workflowId = cleanString(args.workflowId || context.workflowId)
const nodeId = cleanString(args.nodeId || args.agentNodeId || context.agentNodeId)
if (!workflowId || !nodeId) throw new Error('workflowId_and_nodeId_required')
return { workflowId, nodeId }
}
async function handleGetContext() {
const n8nMcpVersion = await loadN8nMcpVersion()
return {
ok: true,
...context,
apiBaseCandidates: engineApiBaseCandidates(),
n8nMcpVersion,
contract: {
sourceOfTruth: 'Engine second-level dc.subworkflow.json',
writeApi: `${context.apiBaseUrl}/subworkflow/patch`,
schemaApi: engineApiUrl(`/api/n8n/schema?kind=nodes&version=${encodeURIComponent(context.schemaVersion)}`),
directCoreWritesAllowed: false,
directFileWritesAllowed: false,
},
}
}
async function handleGetSubworkflow(args) {
const target = targetFromArgs(args)
const q = new URLSearchParams(target)
const result = await engineFetch(`/subworkflow?${q.toString()}`)
return {
ok: true,
...(result && typeof result === 'object' ? result : {}),
graph: normalizeGraphResult(result?.graph, target.workflowId, target.nodeId),
}
}
async function handleApplyPatch(args) {
const target = targetFromArgs(args)
const operations = Array.isArray(args.operations) ? args.operations : []
if (!operations.length) throw new Error('operations_required')
const result = await engineFetch('/subworkflow/patch', {
method: 'POST',
body: JSON.stringify({
workflowId: target.workflowId,
nodeId: target.nodeId,
intent: cleanString(args.intent || 'NDC Agent MCP patch', 500),
expectedUpdatedAt: cleanString(args.expectedUpdatedAt || ''),
operations,
}),
})
if (result?.graph) {
return { ...result, graph: normalizeGraphResult(result.graph, target.workflowId, target.nodeId) }
}
const loaded = await handleGetSubworkflow(target)
return {
ok: true,
...(result && typeof result === 'object' ? result : {}),
graph: loaded.graph,
recoveredAfterEmptyPatchResponse: !result?.graph,
}
}
async function handleSearchNodes(args) {
const query = cleanString(args.query || args.q || '', 160).toLowerCase()
const limit = Math.min(Math.max(Number(args.limit || 12) || 12, 1), 50)
const catalog = await loadNodeCatalog(args.schemaVersion)
const terms = query.split(/\s+/).filter(Boolean)
const matches = catalog
.map((node) => {
const name = String(node?.name || '').toLowerCase()
const displayName = String(node?.displayName || '').toLowerCase()
const description = String(node?.description || '').toLowerCase()
const groups = (Array.isArray(node?.group) ? node.group : []).map((item) => String(item || '').toLowerCase())
const score = !terms.length ? 1 : terms.reduce((acc, term) => {
if (name === term) return acc + 100
if (displayName === term) return acc + 90
if (name.includes(term)) return acc + 25
if (displayName.includes(term)) return acc + 20
if (groups.some((group) => group.includes(term))) return acc + 6
if (description.includes(term)) return acc + 2
return acc
}, 0)
return { node, score }
})
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score || String(a.node?.displayName || '').localeCompare(String(b.node?.displayName || '')))
.slice(0, limit)
.map(({ node }) => ({
name: node?.name || '',
fullType: fullNodeType(node?.name || ''),
displayName: node?.displayName || node?.name || '',
description: node?.description || '',
group: node?.group || [],
versions: versionSummary(node?.version),
}))
return { ok: true, query, count: matches.length, nodes: matches }
}
async function handleGetNodeDefinition(args) {
const nodeType = normalizeTypeName(args.nodeType || args.type || args.name)
if (!nodeType) throw new Error('nodeType_required')
const catalog = await loadNodeCatalog(args.schemaVersion)
const node = catalog.find((item) => (
String(item?.name || '') === nodeType ||
fullNodeType(item?.name || '') === cleanString(args.nodeType || args.type || args.name)
))
if (!node) throw new Error(`node_not_found:${nodeType}`)
return { ok: true, node: compactNodeDefinition(node, Number(args.maxProperties || 80) || 80) }
}
async function handleValidateSubworkflow(args) {
const target = (() => {
try { return targetFromArgs(args) } catch { return { workflowId: context.workflowId, nodeId: context.agentNodeId } }
})()
const graph = args.graph && typeof args.graph === 'object'
? args.graph
: normalizeGraphResult((await handleGetSubworkflow(args)).graph, target.workflowId, target.nodeId)
const nodes = Array.isArray(graph?.nodes) ? graph.nodes : []
const edges = Array.isArray(graph?.edges) ? graph.edges : []
const nodeIds = new Set()
const issues = []
nodes.forEach((node, index) => {
const id = cleanString(node?.id)
if (!id) issues.push({ level: 'error', code: 'node_id_missing', index })
if (id && nodeIds.has(id)) issues.push({ level: 'error', code: 'node_id_duplicate', nodeId: id })
if (id) nodeIds.add(id)
const typeName = cleanString(node?.data?.n8n?.type)
if (!typeName) issues.push({ level: 'warn', code: 'node_type_missing', nodeId: id })
})
edges.forEach((edge, index) => {
const source = cleanString(edge?.source)
const target = cleanString(edge?.target)
if (!source || !target) issues.push({ level: 'error', code: 'edge_endpoint_missing', index })
if (source && !nodeIds.has(source)) issues.push({ level: 'error', code: 'edge_source_missing', edgeId: edge?.id, source })
if (target && !nodeIds.has(target)) issues.push({ level: 'error', code: 'edge_target_missing', edgeId: edge?.id, target })
})
return {
ok: !issues.some((issue) => issue.level === 'error'),
nodesCount: nodes.length,
edgesCount: edges.length,
issues,
}
}
const tools = [
{
name: 'ndc_get_context',
description: 'Return selected NDC Agent Core target context and safety contract.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
},
{
name: 'ndc_get_subworkflow',
description: 'Read the selected Engine second-level workflow graph from dc.subworkflow.json.',
inputSchema: {
type: 'object',
properties: {
workflowId: { type: 'string' },
nodeId: { type: 'string' },
},
additionalProperties: false,
},
},
{
name: 'ndc_apply_subworkflow_patch',
description: 'Apply safe graph patch operations to the selected Engine second-level workflow.',
inputSchema: {
type: 'object',
properties: {
workflowId: { type: 'string' },
nodeId: { type: 'string' },
intent: { type: 'string' },
expectedUpdatedAt: { type: 'string' },
operations: {
type: 'array',
items: { type: 'object', additionalProperties: true },
},
},
required: ['operations'],
additionalProperties: false,
},
},
{
name: 'ndc_search_nodes',
description: 'Search NDC node definitions from the pinned v2.3.2 schema catalog.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string' },
limit: { type: 'number' },
schemaVersion: { type: 'string' },
},
additionalProperties: false,
},
},
{
name: 'ndc_get_node_definition',
description: 'Return a compact NDC node definition, including properties, credentials, inputs, and outputs.',
inputSchema: {
type: 'object',
properties: {
nodeType: { type: 'string' },
schemaVersion: { type: 'string' },
maxProperties: { type: 'number' },
},
required: ['nodeType'],
additionalProperties: false,
},
},
{
name: 'ndc_validate_subworkflow',
description: 'Validate a second-level workflow graph or the selected saved graph for basic structural issues.',
inputSchema: {
type: 'object',
properties: {
workflowId: { type: 'string' },
nodeId: { type: 'string' },
graph: { type: 'object', additionalProperties: true },
},
additionalProperties: false,
},
},
]
async function callTool(name, args) {
if (name === 'ndc_get_context') return handleGetContext()
if (name === 'ndc_get_subworkflow') return handleGetSubworkflow(args)
if (name === 'ndc_apply_subworkflow_patch') return handleApplyPatch(args)
if (name === 'ndc_search_nodes') return handleSearchNodes(args)
if (name === 'ndc_get_node_definition') return handleGetNodeDefinition(args)
if (name === 'ndc_validate_subworkflow') return handleValidateSubworkflow(args)
throw new Error(`unknown_tool:${name}`)
}
async function handleRequest(message) {
const id = message?.id
const method = String(message?.method || '')
if (!method) return
if (method.startsWith('notifications/')) return
try {
if (method === 'initialize') {
return writeMessage(jsonRpcResult(id, {
protocolVersion: message?.params?.protocolVersion || '2025-06-18',
capabilities: { tools: {} },
serverInfo: { name: 'ndc-agent-mcp', version: '0.1.0' },
}))
}
if (method === 'ping') return writeMessage(jsonRpcResult(id, {}))
if (method === 'tools/list') return writeMessage(jsonRpcResult(id, { tools }))
if (method === 'tools/call') {
const name = cleanString(message?.params?.name)
const args = message?.params?.arguments && typeof message.params.arguments === 'object'
? message.params.arguments
: {}
const result = await callTool(name, args)
return writeMessage(jsonRpcResult(id, toolText(result)))
}
if (method === 'resources/list') return writeMessage(jsonRpcResult(id, { resources: [] }))
if (method === 'prompts/list') return writeMessage(jsonRpcResult(id, { prompts: [] }))
return writeMessage(jsonRpcError(id, -32601, `method_not_found:${method}`))
} catch (error) {
return writeMessage(jsonRpcError(id, -32000, error?.message || error, error?.payload))
}
}
function parseJsonMessage(raw) {
try {
return JSON.parse(raw)
} catch {
writeMessage(jsonRpcError(null, -32700, 'parse_error'))
return null
}
}
function consumeJsonLineMessages() {
for (;;) {
const newlineIndex = inputBuffer.indexOf(0x0a)
if (newlineIndex < 0) return
const line = inputBuffer.subarray(0, newlineIndex).toString('utf8')
inputBuffer = inputBuffer.subarray(newlineIndex + 1)
const trimmed = line.trim()
if (!trimmed) continue
const message = parseJsonMessage(trimmed)
if (message) void handleRequest(message)
}
}
function consumeContentLengthMessages() {
for (;;) {
const headerEnd = inputBuffer.indexOf('\r\n\r\n')
const altHeaderEnd = headerEnd < 0 ? inputBuffer.indexOf('\n\n') : -1
const boundary = headerEnd >= 0 ? headerEnd : altHeaderEnd
if (boundary < 0) return
const separatorLength = headerEnd >= 0 ? 4 : 2
const header = inputBuffer.subarray(0, boundary).toString('utf8')
const match = /content-length:\s*(\d+)/i.exec(header)
if (!match) {
inputBuffer = inputBuffer.subarray(boundary + separatorLength)
writeMessage(jsonRpcError(null, -32600, 'content_length_missing'))
continue
}
const contentLength = Number(match[1])
const bodyStart = boundary + separatorLength
const bodyEnd = bodyStart + contentLength
if (inputBuffer.length < bodyEnd) return
const body = inputBuffer.subarray(bodyStart, bodyEnd).toString('utf8')
inputBuffer = inputBuffer.subarray(bodyEnd)
const message = parseJsonMessage(body)
if (message) void handleRequest(message)
}
}
function consumeInputBuffer() {
if (!inputBuffer.length) return
if (!transportMode) {
const trimmedStart = inputBuffer.toString('utf8', 0, Math.min(inputBuffer.length, 32)).trimStart()
if (trimmedStart.startsWith('Content-Length:') || trimmedStart.toLowerCase().startsWith('content-length:')) {
transportMode = 'content-length'
} else if (trimmedStart.startsWith('{')) {
transportMode = 'json-lines'
} else if (inputBuffer.length < 32) {
return
} else {
transportMode = 'json-lines'
}
}
if (transportMode === 'content-length') consumeContentLengthMessages()
else consumeJsonLineMessages()
}
process.stdin.on('data', (chunk) => {
inputBuffer = Buffer.concat([inputBuffer, Buffer.from(chunk)])
consumeInputBuffer()
})
process.stdin.on('end', () => {
if (transportMode === 'json-lines') {
const line = inputBuffer.toString('utf8').trim()
inputBuffer = Buffer.alloc(0)
if (!line) return
const message = parseJsonMessage(line)
if (message) void handleRequest(message)
}
})
if (process.env.NDC_AGENT_MCP_DEBUG_JSONL === '1') {
process.stdin.setEncoding('utf8')
}
process.on('uncaughtException', (error) => {
writeMessage(jsonRpcError(null, -32000, error?.message || error))
})
process.on('unhandledRejection', (reason) => {
writeMessage(jsonRpcError(null, -32000, reason?.message || reason))
})
if (process.stdin.isTTY) {
process.stderr.write('ndc-agent-mcp expects JSON-RPC messages on stdin.\n')
}
'@
Set-Content -Path $workerPath -Value $workerCode -Encoding UTF8
Set-Content -Path $ndcAgentMcpServerPath -Value $ndcAgentMcpServerCode -Encoding UTF8
$startCode = @"
`$ErrorActionPreference = 'Stop'
`$env:AI_BRIDGE_HOST = '0.0.0.0'
`$env:AI_BRIDGE_PORT = '$Port'
`$env:AI_BRIDGE_CODEX_CWD = $(ConvertTo-PsSingleQuoted $WorkspacePath)
`$env:AI_BRIDGE_CODEX_BIN = 'codex'
`$env:AI_BRIDGE_CODEX_ARGS = '["exec","--json","--skip-git-repo-check","-c","model_reasoning_summary=detailed","-c","model_supports_reasoning_summaries=true","-c","use_experimental_reasoning_summary=true","-c","hide_agent_reasoning=false","-c","show_raw_agent_reasoning=false","-"]'
`$env:AI_BRIDGE_ALLOW_ORIGIN = '*'
`$env:AI_BRIDGE_HUB_URL = $(ConvertTo-PsSingleQuoted $HubUrl)
`$env:AI_BRIDGE_HUB_URLS = $(ConvertTo-PsSingleQuoted $hubUrlsText)
`$env:AI_BRIDGE_PAIRING_CODE = $(ConvertTo-PsSingleQuoted $PairingCode)
`$env:AI_BRIDGE_MACHINE_NAME = $(ConvertTo-PsSingleQuoted $resolvedMachineName)
`$env:AI_BRIDGE_ACTIVE_MIRROR_DIR = $(ConvertTo-PsSingleQuoted $activeMirror)
`$env:AI_BRIDGE_ACTIVE_MIRROR_OPEN_IDE = '1'
`$env:Path = $(ConvertTo-PsSingleQuoted $nodeDir) + ';' + $(ConvertTo-PsSingleQuoted $npmPrefix) + ';' + `$env:Path
Set-Location $(ConvertTo-PsSingleQuoted $Root)
& $(ConvertTo-PsSingleQuoted $nodeSource) $(ConvertTo-PsSingleQuoted $workerPath) *>> $(ConvertTo-PsSingleQuoted $logPath)
"@
Set-Content -Path $startScript -Value $startCode -Encoding UTF8
$watchdogCode = @"
`$ErrorActionPreference = 'SilentlyContinue'
`$startScript = $(ConvertTo-PsSingleQuoted $startScript)
`$logPath = $(ConvertTo-PsSingleQuoted $logPath)
`$healthUrl = $(ConvertTo-PsSingleQuoted "http://127.0.0.1:$Port$HealthPath")
`$healthy = `$false
try {
`$request = [System.Net.HttpWebRequest]::Create(`$healthUrl)
`$request.Method = 'GET'
`$request.Proxy = `$null
`$request.Timeout = 2000
`$request.ReadWriteTimeout = 2000
`$response = `$request.GetResponse()
try {
`$stream = `$response.GetResponseStream()
`$reader = New-Object System.IO.StreamReader(`$stream)
`$body = `$reader.ReadToEnd()
`$healthy = `$response.StatusCode -eq 200 -and `$body -match '"ok"\s*:\s*true'
} finally {
`$response.Close()
}
} catch {}
if (-not `$healthy) {
try {
Add-Content -Path `$logPath -Value ("[{0}] watchdog: health probe failed; starting bridge." -f (Get-Date).ToString('s')) -Encoding UTF8
} catch {}
Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile', '-WindowStyle', 'Hidden', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', `$startScript) -WindowStyle Hidden | Out-Null
}
"@
Set-Content -Path $watchdogScript -Value $watchdogCode -Encoding UTF8
$config = [ordered]@{
service = 'NDC AI Workspace Bridge'
port = $Port
installRoot = $Root
workspace = $WorkspacePath
codexHome = $bridgeCodexHome
worker = $workerPath
startScript = $startScript
watchdogScript = $watchdogScript
watchdogEnabled = [bool]$EnableWatchdog
taskName = $TaskName
watchdogTaskName = "$TaskName Watchdog"
healthPath = $HealthPath
conversationPath = $ConversationPath
activeMirror = $activeMirror
}
$config | ConvertTo-Json -Depth 5 | Set-Content -Path $configPath -Encoding UTF8
Write-Ok "Installed to $Root"
return @{
WorkerPath = $workerPath
StartScript = $startScript
WatchdogScript = $watchdogScript
ConfigPath = $configPath
Logs = $logs
}
}
function Register-BridgeAutostart {
param(
[string]$StartScript,
[string]$WatchdogScript
)
$watchdogTaskName = "$TaskName Watchdog"
if (-not $EnableWatchdog) {
try {
Stop-ScheduledTask -TaskName $watchdogTaskName -ErrorAction SilentlyContinue
} catch {}
try {
Unregister-ScheduledTask -TaskName $watchdogTaskName -Confirm:$false -ErrorAction SilentlyContinue
Write-Ok "Watchdog task disabled: $watchdogTaskName"
} catch {}
}
if ($NoAutostart) {
Write-Warn 'Autostart skipped by flag.'
return
}
Write-Step 'Registering Windows autostart'
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -WindowStyle Hidden -NonInteractive -ExecutionPolicy Bypass -File `"$StartScript`""
$triggers = @()
$triggers += New-ScheduledTaskTrigger -AtLogOn
$triggers += New-ScheduledTaskTrigger -AtStartup
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Days 3650) -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -MultipleInstances IgnoreNew
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $triggers -Settings $settings -Description 'NDC AI Workspace Bridge for NODE.DC AI Workspace' -RunLevel Highest -Force | Out-Null
Write-Ok "Scheduled task registered: $TaskName"
if ($EnableWatchdog -and $WatchdogScript) {
$watchdogAction = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -WindowStyle Hidden -NonInteractive -ExecutionPolicy Bypass -File `"$WatchdogScript`""
$watchdogTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(2) -RepetitionInterval (New-TimeSpan -Minutes 5) -RepetitionDuration (New-TimeSpan -Days 3650)
$watchdogSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5) -MultipleInstances IgnoreNew
Register-ScheduledTask -TaskName $watchdogTaskName -Action $watchdogAction -Trigger $watchdogTrigger -Settings $watchdogSettings -Description 'NDC AI Workspace Bridge watchdog' -RunLevel Highest -Force | Out-Null
Write-Ok "Watchdog task registered: $watchdogTaskName"
}
}
function Start-BridgeNow {
param([string]$StartScript)
Write-Step 'Starting bridge now'
Stop-BridgeNow
Start-Process -FilePath 'powershell.exe' -ArgumentList "-NoProfile -WindowStyle Hidden -NonInteractive -ExecutionPolicy Bypass -File `"$StartScript`"" -WindowStyle Hidden | Out-Null
Write-Ok 'Bridge process started.'
}
function Stop-BridgeNow {
try {
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
} catch {}
try {
Stop-ScheduledTask -TaskName "$TaskName Watchdog" -ErrorAction SilentlyContinue
} catch {}
try {
$escapedRoot = [Regex]::Escape($InstallRoot)
$processes = Get-CimInstance Win32_Process |
Where-Object {
$_.CommandLine -match 'worker\.mjs' -and
($_.CommandLine -match $escapedRoot -or $_.CommandLine -match 'AIWorkspaceBridge')
}
foreach ($process in $processes) {
try {
Stop-Process -Id $process.ProcessId -Force -ErrorAction Stop
Write-Ok "Stopped existing bridge process: $($process.ProcessId)"
} catch {}
}
} catch {}
}
function Get-BridgeLogTail {
$logPath = Join-Path $InstallRoot 'logs\bridge.log'
if (-not (Test-Path $logPath)) { return '' }
try {
return ((Get-Content -Path $logPath -Tail 80 -ErrorAction Stop) -join [Environment]::NewLine)
} catch {
return ''
}
}
function Get-PortOwnerReport {
param([int]$LocalPort = $Port)
try {
$listeners = Get-NetTCPConnection -LocalPort $LocalPort -State Listen -ErrorAction SilentlyContinue |
Sort-Object OwningProcess, LocalAddress -Unique
if (-not $listeners) { return '' }
$lines = @()
foreach ($listener in $listeners) {
$processId = [int]$listener.OwningProcess
$process = Get-CimInstance Win32_Process -Filter "ProcessId=$processId" -ErrorAction SilentlyContinue
if ($process) {
$commandLine = ([string]$process.CommandLine).Trim()
if ($commandLine.Length -gt 500) { $commandLine = $commandLine.Substring(0, 500) + '...' }
$lines += ("{0}:{1} PID {2} {3} {4}" -f $listener.LocalAddress, $listener.LocalPort, $processId, $process.Name, $commandLine)
} else {
$lines += ("{0}:{1} PID {2}" -f $listener.LocalAddress, $listener.LocalPort, $processId)
}
}
return ($lines -join [Environment]::NewLine)
} catch {
return ''
}
}
function Test-BridgeProcess {
param([object]$Process)
if (-not $Process) { return $false }
$commandLine = [string]$Process.CommandLine
if (-not $commandLine) { return $false }
try {
$escapedRoot = [Regex]::Escape($InstallRoot)
return (
$commandLine -match 'worker\.mjs' -and
($commandLine -match $escapedRoot -or $commandLine -match 'AIWorkspaceBridge')
)
} catch {
return ($commandLine -match 'worker\.mjs' -and $commandLine -match 'AIWorkspaceBridge')
}
}
function Get-BlockingPortOwners {
param([int]$LocalPort)
try {
$listeners = Get-NetTCPConnection -LocalPort $LocalPort -State Listen -ErrorAction SilentlyContinue |
Sort-Object OwningProcess, LocalAddress -Unique
if (-not $listeners) { return @() }
$blocking = @()
foreach ($listener in $listeners) {
$processId = [int]$listener.OwningProcess
$process = Get-CimInstance Win32_Process -Filter "ProcessId=$processId" -ErrorAction SilentlyContinue
if (Test-BridgeProcess $process) { continue }
$blocking += [pscustomobject]@{
LocalAddress = $listener.LocalAddress
LocalPort = $listener.LocalPort
ProcessId = $processId
Name = if ($process) { $process.Name } else { '' }
CommandLine = if ($process) { [string]$process.CommandLine } else { '' }
}
}
return @($blocking)
} catch {
return @()
}
}
function Resolve-BridgePort {
$requested = [int]$Port
for ($candidate = $requested; $candidate -le ($requested + 20); $candidate++) {
$blocking = @(Get-BlockingPortOwners -LocalPort $candidate)
if ($blocking.Count -eq 0) {
if ($candidate -ne $requested) {
Write-Warn "TCP $requested is occupied by another service; using TCP $candidate for local bridge health."
}
return $candidate
}
}
$report = Get-PortOwnerReport -LocalPort $requested
if ($report) {
throw "No free bridge port near $requested. Current listener on TCP ${requested}:`n$report"
}
throw "No free bridge port near $requested."
}
function Try-StartBridgeViaTask {
if ($NoAutostart) { return }
try {
Start-ScheduledTask -TaskName $TaskName
Write-Ok 'Autostart task started for validation.'
} catch {
Write-Warn "Could not start scheduled task: $($_.Exception.Message)"
}
}
function Ensure-Firewall {
if ($NoFirewall) {
Write-Warn 'Firewall rule skipped by flag.'
return
}
Write-Step 'Configuring Windows Firewall'
$displayName = "NDC AI Workspace Bridge $Port"
$existing = Get-NetFirewallRule -DisplayName $displayName -ErrorAction SilentlyContinue
if ($existing) {
$existing | Set-NetFirewallRule -Enabled True -Profile Domain,Private,Public -Direction Inbound -Action Allow
Write-Ok "Firewall rule updated for TCP ${Port}: $displayName"
return
}
New-NetFirewallRule -DisplayName $displayName -Direction Inbound -Action Allow -Protocol TCP -LocalPort $Port -Profile Domain,Private,Public | Out-Null
Write-Ok "Firewall rule added for TCP $Port"
}
function Normalize-PairingCode {
param([string]$Value)
if (-not $Value) { return '' }
return (($Value -replace '[^A-Za-z0-9]', '').ToUpperInvariant())
}
function Get-LocalBridgeHealth {
$healthUrl = "http://127.0.0.1:$Port$HealthPath"
try {
$request = [System.Net.HttpWebRequest]::Create($healthUrl)
$request.Method = 'GET'
$request.Proxy = $null
$request.Timeout = 2000
$request.ReadWriteTimeout = 2000
$response = $request.GetResponse()
try {
$stream = $response.GetResponseStream()
$reader = New-Object System.IO.StreamReader($stream)
$body = $reader.ReadToEnd()
$data = $null
try { $data = $body | ConvertFrom-Json } catch {}
return [pscustomobject]@{
Ok = ($response.StatusCode -eq 200 -and $null -ne $data -and $data.ok -eq $true)
Url = $healthUrl
StatusCode = [int]$response.StatusCode
Body = $body
Data = $data
Error = ''
}
} finally {
$response.Close()
}
} catch {
$statusCode = 0
$body = ''
$response = $_.Exception.Response
if ($response) {
try {
$statusCode = [int]$response.StatusCode
$stream = $response.GetResponseStream()
if ($stream) {
$reader = New-Object System.IO.StreamReader($stream)
$body = $reader.ReadToEnd()
}
} catch {}
try { $response.Close() } catch {}
}
return [pscustomobject]@{
Ok = $false
Url = $healthUrl
StatusCode = $statusCode
Body = $body
Data = $null
Error = $_.Exception.Message
}
}
}
function Wait-Health {
Write-Step 'Checking bridge health'
$lastError = ''
$lastStatusCode = 0
$lastBody = ''
for ($i = 0; $i -lt 30; $i++) {
$health = Get-LocalBridgeHealth
if ($health.Ok) {
Write-Ok "Health OK: $($health.Url)"
return $health.Data
}
$lastError = $health.Error
$lastStatusCode = [int]$health.StatusCode
$lastBody = [string]$health.Body
Start-Sleep -Seconds 1
}
Write-Warn "Bridge did not answer with JSON health yet: http://127.0.0.1:$Port$HealthPath"
if ($lastStatusCode -gt 0) { Write-Warn "Last health HTTP status: $lastStatusCode" }
if ($lastError) { Write-Warn "Last health error: $lastError" }
if ($lastBody) {
$bodyPreview = ($lastBody -replace '\s+', ' ').Trim()
if ($bodyPreview.Length -gt 500) { $bodyPreview = $bodyPreview.Substring(0, 500) + '...' }
Write-Warn "Last health body: $bodyPreview"
}
$portOwner = Get-PortOwnerReport
if ($portOwner) {
Write-Host "Current listener on TCP ${Port}:" -ForegroundColor Yellow
Write-Host $portOwner -ForegroundColor Yellow
} else {
Write-Warn "No listener detected on TCP $Port."
}
return $null
}
function Wait-HubConnection {
param([object]$InitialHealth)
if (-not $HubUrl -or -not $PairingCode) {
return $true
}
Write-Step 'Checking Cloud Hub connection'
$expectedPairing = Normalize-PairingCode $PairingCode
$lastHubError = ''
$lastHubUrl = ''
for ($i = 0; $i -lt 60; $i++) {
$health = if ($i -eq 0 -and $InitialHealth) { $InitialHealth } else { (Get-LocalBridgeHealth).Data }
if ($health) {
$hub = $health.hub
$hubConnected = ($health.hubConnected -eq $true)
if ($hub) {
$hubConnected = $hubConnected -or ($hub.connected -eq $true)
$lastHubError = [string]$hub.lastError
$lastHubUrl = [string]$hub.url
$actualPairing = Normalize-PairingCode ([string]$hub.pairingCode)
if ($hubConnected -and $actualPairing -eq $expectedPairing) {
if ($lastHubUrl) {
Write-Ok "Cloud Hub connected: $lastHubUrl"
} else {
Write-Ok 'Cloud Hub connected.'
}
return $true
}
}
}
Start-Sleep -Seconds 1
}
Write-Warn 'Cloud Hub connection was not confirmed.'
if ($lastHubError) { Write-Warn "Last Hub error: $lastHubError" }
$hubUrlCandidates = @(Get-HubUrlCandidates)
if ($hubUrlCandidates.Count -gt 0) {
Write-Host "Hub candidates:" -ForegroundColor Yellow
foreach ($candidate in $hubUrlCandidates) {
Write-Host " $candidate" -ForegroundColor Yellow
}
}
return $false
}
function Get-BridgeIps {
$ips = @()
try {
$ips = Get-NetIPAddress -AddressFamily IPv4 |
Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' } |
Sort-Object InterfaceMetric |
Select-Object -ExpandProperty IPAddress -Unique
} catch {}
if (-not $ips -or $ips.Count -eq 0) {
$ips = [System.Net.Dns]::GetHostAddresses($env:COMPUTERNAME) |
Where-Object { $_.AddressFamily -eq 'InterNetwork' -and $_.IPAddressToString -notlike '127.*' } |
ForEach-Object { $_.IPAddressToString }
}
return @($ips)
}
function Print-Result {
param(
[string]$Root,
[string]$WorkspacePath,
[bool]$Healthy,
[bool]$HubHealthy
)
$ips = Get-BridgeIps
$endpoint = if ($ips.Count -gt 0) { "http://$($ips[0]):$Port" } else { "http://<THIS_MACHINE_IP>:$Port" }
$hubUrlCandidates = @(Get-HubUrlCandidates)
Write-Step 'Done'
Write-Host "AI Workspace settings:"
Write-Host " Type: Codex worker"
Write-Host " Model: codex"
if ($HubUrl -and $PairingCode) {
Write-Host " Connection: Cloud Hub" -ForegroundColor Green
if ($HubHealthy) {
Write-Host " Hub connection: confirmed" -ForegroundColor Green
} else {
Write-Host " Hub connection: not confirmed" -ForegroundColor Yellow
}
} else {
Write-Host " Bridge endpoint: $endpoint" -ForegroundColor Green
}
Write-Host ""
Write-Host "Local health:"
Write-Host " http://127.0.0.1:$Port$HealthPath"
Write-Host ""
Write-Host "Install root:"
Write-Host " $Root"
Write-Host "Workspace:"
Write-Host " $WorkspacePath"
Write-Host "Autostart task:"
Write-Host " $TaskName"
if ($hubUrlCandidates.Count -gt 0 -and $PairingCode) {
Write-Host "Hub candidates:"
foreach ($candidate in $hubUrlCandidates) {
Write-Host " $candidate"
}
Write-Host "Pairing code:"
Write-Host " $PairingCode" -ForegroundColor Green
}
if ($ips.Count -gt 1) {
Write-Host ""
Write-Host "Other possible endpoints:"
foreach ($ip in $ips | Select-Object -Skip 1) {
Write-Host " http://${ip}:$Port"
}
}
$copyText = @"
Type: Codex worker
Model: codex
Connection: $(if ($HubUrl -and $PairingCode) { 'Cloud Hub' } else { $endpoint })
Hub connection: $(if ($HubUrl -and $PairingCode) { if ($HubHealthy) { 'confirmed' } else { 'not confirmed' } } else { 'not used' })
Health: http://127.0.0.1:$Port$HealthPath
Workspace: $WorkspacePath
Hub candidates: $($hubUrlCandidates -join ', ')
Pairing code: $PairingCode
"@
Write-InstallResult $copyText
try {
Set-Clipboard -Value $copyText
Write-Ok 'Connection details copied to clipboard.'
} catch {}
if (-not $Healthy) {
Write-Warn 'If Engine cannot connect, check Windows Firewall profile and bridge.log in the install root.'
$logTail = Get-BridgeLogTail
if ($logTail) {
Write-Host ""
Write-Host "bridge.log tail:" -ForegroundColor Yellow
Write-Host $logTail
try {
Add-Content -Path (Get-ResultPath) -Value "`nbridge.log tail:`n$logTail" -Encoding UTF8
} catch {}
}
}
}
function Main {
if (-not $Confirmed) {
Write-Host ""
Write-Host "NDC AI Workspace Bridge will install or update Node.js, Codex CLI, a local bridge service, autostart, and firewall access."
Write-Host "Target port: $Port"
}
Request-Elevation
$workspacePath = Get-WorkspacePath
Ensure-Node
Ensure-Codex
Ensure-CodexLogin
Repair-CodexMcpConfig
Ensure-AppMcpConfigs
if (-not $AppMcpServersJson) { Ensure-OpsMcpConfig }
Stop-BridgeNow
$script:Port = Resolve-BridgePort
$files = Write-WorkerFiles -Root $InstallRoot -WorkspacePath $workspacePath
Ensure-Firewall
Register-BridgeAutostart -StartScript $files.StartScript -WatchdogScript $files.WatchdogScript
Start-BridgeNow -StartScript $files.StartScript
$health = Wait-Health
if (-not $health) {
Try-StartBridgeViaTask
$health = Wait-Health
}
$hubHealthy = $true
if ($health -and $HubUrl -and $PairingCode) {
$hubHealthy = Wait-HubConnection -InitialHealth $health
} elseif ($HubUrl -and $PairingCode) {
$hubHealthy = $false
}
Print-Result -Root $InstallRoot -WorkspacePath $workspacePath -Healthy ([bool]$health -and [bool]$hubHealthy) -HubHealthy ([bool]$hubHealthy)
}
try {
Main
} catch {
$errorText = @"
NDC AI Workspace Bridge install failed.
Error:
$($_.Exception.Message)
Script:
$PSCommandPath
Install root:
$InstallRoot
"@
Write-Host ""
Write-Host $errorText -ForegroundColor Red
Write-InstallResult $errorText
}