4294 lines
150 KiB
PowerShell
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
|
|
}
|