# 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 terminateProcessTree(child, signal = 'SIGTERM') { if (!child) return if (process.platform === 'win32' && child.pid) { try { const args = ['/PID', String(child.pid), '/T'] if (signal === 'SIGKILL') args.push('/F') const killer = spawn('taskkill', args, { windowsHide: true, stdio: 'ignore', shell: false, }) killer.unref?.() return } catch {} } try { child.kill(signal) } catch {} } function registerActiveCodexRun(control = {}, child, onEvent = () => {}) { const requestId = cleanRunKey(control.requestId, 120) const threadId = cleanRunKey(control.threadId, 160) 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 {} terminateProcessTree(run.child, 'SIGTERM') run.stopTimer = setTimeout(() => { if (run.closed) return terminateProcessTree(run.child, 'SIGKILL') }, 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 toolProfile = runProfile.toolProfile && typeof runProfile.toolProfile === 'object' ? runProfile.toolProfile : {} const assistantActions = toolProfile.assistantActions && typeof toolProfile.assistantActions === 'object' ? toolProfile.assistantActions : {} const assistantActionIds = Array.isArray(assistantActions.actionIds) ? assistantActions.actionIds.map((item) => String(item || '').trim()).filter(Boolean) : [] const runProfilePolicyPrompt = String(runProfile.policyPrompt || '').trim() const userMessage = String(payload?.message || '').trim() const history = payload?.resume ? [] : normalizeHistory(payload?.history || []) 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=&nodeId=.', '- Apply graph edits with POST /subworkflow/patch using JSON: { workflowId, nodeId, intent, operations }.', '- Prefer the MCP tools exposed by the ndc_agent_core server: ndc_get_context, ndc_get_subworkflow, ndc_search_nodes, ndc_get_node_definition, ndc_apply_subworkflow_patch, ndc_validate_subworkflow, assistant_action_call.', '- Do not use direct NDC core runtime MCP servers, local workflow files, or shell probes for graph edits in this mode.', '- Use only ndc_agent_core MCP tools for the selected second-level workflow and stop after a cancellation, fetch, or policy error with the exact tool name and error.', '- In public answers, use only NDC labels: NDC workflow, NDC node, NDC node type, NDC nodebase, and NDC Agent Core.', '- 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.', ], '', 'Assistant action routing contract:', `- Assistant action ids available in this run: ${assistantActionIds.length ? assistantActionIds.join(', ') : 'none'}.`, '- For Launcher/HUB/admin/access/users/invites/roles/service grants requests, use only the ndc_agent_core MCP tool assistant_action_call.', '- Do not use codex_apps readonly connectors, read_handoff, local files, shell search, logs, or workspace scans for Launcher/HUB live administrative data.', '- Use phase="execute" for read-only action calls after selecting the structured action id.', '- Use phase="preview" before any privileged/write action, ask for explicit confirmation, then use phase="execute" only after confirmation.', '- Useful read action ids when advertised: hub.access_request.list_pending, hub.invite.list_pending, hub.user.read_admin_summary.', '- If assistant_action_call is unavailable or the gateway returns an error, report that exact tool/error and stop; do not guess from local files.', ] : [], ...(isOpsMode || opsContext.opsWorkspaceSlug || opsContext.opsProjectId ? [ '', '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.', '- If assistant action ids include ops.card.list_recent, ops.card.create, or ops.card.add_comment, these are the canonical Ops card actions for this run.', '- Use assistant_action_call phase="execute" for Ops card reads after selecting ops.card.list_recent.', '- Use assistant_action_call phase="preview" before Ops card create/comment writes, ask for explicit confirmation, then call phase="execute" with the returned confirmation token.', '- Before writing Ops tasks, use the Ops MCP project/context tools when available and include a unique idempotency key for write tools.', '- If direct Ops MCP tools are unavailable but the Ops assistant action ids are advertised, do not refuse; route through assistant_action_call.', '- If neither direct Ops MCP tools nor Ops assistant action ids are available, say that the Ops context is selected but live Ops tools are unavailable.', '- Do not claim that an Engine workflow or Engine agent node is required for Ops task writes.', '- If the current request is about Ops tasks and Ops workspace/project are selected, treat that as the writable Ops target.', '- In public answers, use NODE.DC/Ops labels and never expose internal vendor names.', ] : []), ...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(/([\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 runProfile = payload?.runProfile && typeof payload.runProfile === 'object' ? payload.runProfile : {} const toolProfile = runProfile.toolProfile && typeof runProfile.toolProfile === 'object' ? runProfile.toolProfile : {} const assistantActions = toolProfile.assistantActions && typeof toolProfile.assistantActions === 'object' ? toolProfile.assistantActions : {} const apiBaseUrl = deriveNdcAgentMcpApiBaseUrl(context) return { workflowId: String(context.workflowId || '').trim(), 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, assistantActions: { actionIds: Array.isArray(assistantActions.actionIds) ? assistantActions.actionIds.map((item) => String(item || '').trim()).filter(Boolean) : [], }, assistantActionOwner: runProfile.owner && typeof runProfile.owner === 'object' ? runProfile.owner : {}, assistantActionGatewayUrl: String(assistantActions.gatewayUrl || '').trim(), assistantActionGatewayToken: String(assistantActions.gatewayToken || '').trim(), } } 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 (isPrivateNetworkUrl(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 isPrivateNetworkUrl(value) { try { const url = new URL(value) const host = url.hostname.toLowerCase() if (host === 'localhost' || host === '0.0.0.0' || host === '::1' || host.startsWith('127.')) return true if (host.startsWith('192.168.')) return true if (host.startsWith('10.')) return true if (host.startsWith('169.254.')) return true const match = host.match(/^172\.(\d+)\./) return Boolean(match && Number(match[1]) >= 16 && Number(match[1]) <= 31) } catch { return false } } function isHttpUrl(value) { try { const url = new URL(value) 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 = '' 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, NDC_AGENT_MCP_FETCH_TIMEOUT_MS: process.env.NDC_AGENT_MCP_FETCH_TIMEOUT_MS || '30000', AI_WORKSPACE_ASSISTANT_ACTION_FETCH_TIMEOUT_MS: process.env.AI_WORKSPACE_ASSISTANT_ACTION_FETCH_TIMEOUT_MS || '45000', AI_BRIDGE_PAIRING_CODE: PAIRING_CODE, } : {}), }, 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') } const CODEX_ASSISTANT_TEXT_MAX = 250000 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, CODEX_ASSISTANT_TEXT_MAX) 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, CODEX_ASSISTANT_TEXT_MAX) 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: markdownText(item.text || '', CODEX_ASSISTANT_TEXT_MAX) } 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.' }) terminateProcessTree(child, '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) terminateProcessTree(child, '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) terminateProcessTree(child, '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 DEFAULT_ASSISTANT_ACTION_GATEWAY_PATH = '/api/ai-workspace/assistant/v1/actions' const ASSISTANT_ACTION_FETCH_TIMEOUT_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_ACTION_FETCH_TIMEOUT_MS || ENGINE_FETCH_TIMEOUT_MS) const context = parseContext() const nodeCatalogCache = new Map() let transportMode = null 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 {} } const assistantActions = parseObject( parsed.assistantActions || process.env.AI_WORKSPACE_ASSISTANT_ACTIONS || {}, ) return { modeId: cleanString(parsed.modeId || 'ndc-agent-core'), workflowId: cleanString(parsed.workflowId || process.env.NDC_AGENT_MCP_WORKFLOW_ID || ''), workflowTitle: cleanString(parsed.workflowTitle || process.env.NDC_AGENT_MCP_WORKFLOW_TITLE || ''), agentNodeId: cleanString(parsed.agentNodeId || process.env.NDC_AGENT_MCP_NODE_ID || ''), 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')), assistantActions, assistantActionOwner: parseObject(parsed.assistantActionOwner || {}), assistantActionGatewayUrl: cleanHttpUrl( parsed.assistantActionGatewayUrl || process.env.AI_WORKSPACE_ASSISTANT_ACTION_GATEWAY_URL || deriveAssistantActionGatewayUrlFromHub() || '', ), assistantActionGatewayToken: cleanString( parsed.assistantActionGatewayToken || process.env.AI_WORKSPACE_ASSISTANT_ACTION_GATEWAY_TOKEN || '', 4000, ), } } function cleanString(value, max = 1000) { return String(value || '').trim().slice(0, max) } function parseObject(value, fallback = {}) { if (value && typeof value === 'object' && !Array.isArray(value)) return value const text = String(value || '').trim() if (!text) return fallback try { const parsed = JSON.parse(text) return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : fallback } catch { return fallback } } function cleanHttpUrl(value) { const text = cleanString(value, 2000).replace(/\/+$/, '') if (!text) return '' try { const url = new URL(text) if (url.protocol !== 'http:' && url.protocol !== 'https:') return '' url.username = '' url.password = '' return url.toString().replace(/\/+$/, '') } catch { return '' } } function cleanApiBase(value) { const raw = cleanString(value || DEFAULT_API_BASE) return raw.replace(/\/+$/, '') || DEFAULT_API_BASE } 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 isPrivateNetworkUrl(value) { try { const host = new URL(value).hostname.toLowerCase() if (host === 'localhost' || host === '0.0.0.0' || host === '::1' || host.startsWith('127.')) return true if (host.startsWith('192.168.')) return true if (host.startsWith('10.')) return true if (host.startsWith('169.254.')) return true const match = host.match(/^172\.(\d+)\./) return Boolean(match && Number(match[1]) >= 16 && Number(match[1]) <= 31) } catch { return false } } function hubOriginToApiBase(value) { try { const url = new URL(cleanString(value, 1000)) 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 hubOriginToAssistantActionGateway(value) { try { const url = new URL(cleanString(value, 1000)) const host = url.hostname.toLowerCase() if (!host) return '' if (url.protocol === 'wss:') url.protocol = 'https:' else if (url.protocol === 'ws:') url.protocol = 'http:' else if (url.protocol !== 'http:' && url.protocol !== 'https:') return '' url.pathname = '' url.search = '' url.hash = '' const origin = url.toString().replace(/\/+$/, '') return `${origin}${DEFAULT_ASSISTANT_ACTION_GATEWAY_PATH}` } catch { return '' } } function deriveAssistantActionGatewayUrlFromHub() { const candidates = [ hubOriginToAssistantActionGateway(process.env.AI_BRIDGE_HUB_URL), ...splitList(process.env.AI_BRIDGE_HUB_URLS).map(hubOriginToAssistantActionGateway), ].filter(Boolean) return uniqueStrings(candidates)[0] || '' } function cleanPairingCode(value) { return cleanString(value, 100).toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 32) } 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 && !isPrivateNetworkUrl(explicit)) { return uniqueStrings([explicit, envBase]) } if (envBase && !isPrivateNetworkUrl(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 assistantActionGatewayEndpoint() { const base = cleanHttpUrl(context.assistantActionGatewayUrl) if (!base) return '' if (base.endsWith(DEFAULT_ASSISTANT_ACTION_GATEWAY_PATH) || base.endsWith('/actions')) return base return `${base}${DEFAULT_ASSISTANT_ACTION_GATEWAY_PATH}` } function assistantActionIds() { return Array.isArray(context.assistantActions?.actionIds) ? context.assistantActions.actionIds.map((item) => cleanString(item, 200)).filter(Boolean) : [] } function assistantActionOwnerHeaders() { const owner = parseObject(context.assistantActionOwner, {}) const headers = {} const put = (name, value) => { const text = cleanString(value, 1000) if (text) headers[name] = text } put('x-nodedc-user-id', owner.userId || owner.user_id) put('x-nodedc-user-email', owner.email) put('x-nodedc-user-role', owner.role) const groups = Array.isArray(owner.groups) ? owner.groups.map((item) => cleanString(item, 120)).filter(Boolean).join(',') : owner.groups put('x-nodedc-user-groups', groups) return headers } async function assistantActionFetch(payload = {}) { const endpoint = assistantActionGatewayEndpoint() const token = context.assistantActionGatewayToken if (!endpoint || !token) { return { ok: false, decision: 'assistant_action_gateway_unavailable', reason: 'Assistant action gateway URL/token is not configured for this run.', actionIds: assistantActionIds(), } } let res = null let text = '' let json = null try { res = await fetch(endpoint, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...assistantActionOwnerHeaders(), }, body: JSON.stringify(payload), signal: AbortSignal.timeout(ASSISTANT_ACTION_FETCH_TIMEOUT_MS), }) text = await res.text().catch(() => '') try { json = text ? JSON.parse(text) : null } catch {} } catch (error) { const out = new Error(`assistant_action_gateway_fetch_failed:${fetchFailureText(error)}`) out.payload = { decision: 'assistant_action_gateway_fetch_failed' } throw out } if (!res.ok || json?.ok === false) { const out = new Error(json?.message || json?.error || `assistant_action_http_${res.status}`) out.status = res.status out.payload = json && typeof json === 'object' ? json : { status: res.status, preview: cleanString(text, 400) } throw out } if (!json || typeof json !== 'object') { const out = new Error(`assistant_action_non_json_response:${res.status}`) out.status = 502 out.payload = { status: res.status, preview: cleanString(text, 400) } throw out } return json } function engineApiUrl(pathname) { return engineApiUrls(pathname)[0] || '' } 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 '' if (isPackageQualifiedNodeType(raw)) return raw return raw.startsWith('n8n-nodes-base.') ? raw : `n8n-nodes-base.${raw}` } function isPackageQualifiedNodeType(value) { return /^(?:@[a-z0-9._-]+\/)?n8n-nodes-[a-z0-9._-]+\.[a-z0-9._-]+$/i.test(cleanString(value, 240)) } function catalogNodeRuntimeType(node) { const explicit = [node?.runtimeType, node?.fullType, node?.publicType, node?.type] .map((value) => cleanString(value, 240)) .find(isPackageQualifiedNodeType) if (explicit) return explicit const packageName = cleanString(node?.packageName || node?.package, 160) const name = cleanString(node?.name, 160) if (name && /^(?:@[a-z0-9._-]+\/)?n8n-nodes-[a-z0-9._-]+$/i.test(packageName)) { return `${packageName}.${name}` } return fullNodeType(node?.publicType || name) } 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 || '', publicType: node?.publicType || node?.name || '', fullType: catalogNodeRuntimeType(node), 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, assistantActionGatewayConfigured: Boolean(context.assistantActionGatewayUrl && context.assistantActionGatewayToken), 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 handleAssistantActionCall(args = {}) { const phase = cleanString(args.phase || args.mode || 'preview', 40) if (!['preview', 'dry-run', 'execute'].includes(phase)) throw new Error('assistant_action_phase_invalid') const input = parseObject(args.input || {}, {}) const actionId = cleanString(args.actionId || input.actionId || '', 240) const intent = cleanString(args.intent || input.intent || '', 2000) if (actionId) input.actionId = actionId if (intent) input.intent = intent const availableIds = assistantActionIds() if (actionId && availableIds.length && !availableIds.includes(actionId)) { throw new Error(`assistant_action_not_advertised:${actionId}`) } if (!actionId && !intent) { throw new Error('assistant_action_input_required') } const confirmationToken = cleanString( args.confirmationToken || args.confirmation?.token || input.confirmationToken || '', 2000, ) if (confirmationToken) input.confirmationToken = confirmationToken return assistantActionFetch({ phase, input, ...(confirmationToken ? { confirmationToken } : {}), }) } async function handleGetSubworkflow(args) { const target = targetFromArgs(args) const q = new URLSearchParams(target) 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 || '', publicType: node?.publicType || node?.name || '', fullType: catalogNodeRuntimeType(node), 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 || String(item?.publicType || '') === nodeType || catalogNodeRuntimeType(item) === 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, }, }, { name: 'assistant_action_call', description: 'Call the NODE.DC assistant action layer after interpreting user intent. Use execute for read actions after structured action selection; use preview before any privileged/write action and execute only after explicit confirmation.', inputSchema: { type: 'object', properties: { phase: { type: 'string', enum: ['preview', 'dry-run', 'execute'] }, actionId: { type: 'string' }, intent: { type: 'string' }, input: { type: 'object', additionalProperties: true }, confirmationToken: { type: 'string' }, confirmation: { type: 'object', additionalProperties: true }, }, additionalProperties: false, }, }, ] async function callTool(name, args) { 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) if (name === 'assistant_action_call') return handleAssistantActionCall(args) throw new Error(`unknown_tool:${name}`) } async function handleRequest(message) { const id = message?.id const method = String(message?.method || '') if (!method) return if (method.startsWith('notifications/')) return try { if (method === 'initialize') { return writeMessage(jsonRpcResult(id, { protocolVersion: message?.params?.protocolVersion || '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'ndc-agent-mcp', version: '0.1.0' }, })) } if (method === 'ping') return writeMessage(jsonRpcResult(id, {})) if (method === 'tools/list') return writeMessage(jsonRpcResult(id, { tools })) if (method === 'tools/call') { const name = cleanString(message?.params?.name) const args = message?.params?.arguments && typeof message.params.arguments === 'object' ? message.params.arguments : {} const result = await callTool(name, args) return writeMessage(jsonRpcResult(id, toolText(result))) } if (method === 'resources/list') return writeMessage(jsonRpcResult(id, { resources: [] })) if (method === 'prompts/list') return writeMessage(jsonRpcResult(id, { prompts: [] })) return writeMessage(jsonRpcError(id, -32601, `method_not_found:${method}`)) } catch (error) { return writeMessage(jsonRpcError(id, -32000, error?.message || error, error?.payload)) } } function parseJsonMessage(raw) { try { return JSON.parse(raw) } catch { writeMessage(jsonRpcError(null, -32700, 'parse_error')) return null } } function consumeJsonLineMessages() { for (;;) { const newlineIndex = inputBuffer.indexOf(0x0a) if (newlineIndex < 0) return const line = inputBuffer.subarray(0, newlineIndex).toString('utf8') inputBuffer = inputBuffer.subarray(newlineIndex + 1) const trimmed = line.trim() if (!trimmed) continue const message = parseJsonMessage(trimmed) if (message) void handleRequest(message) } } function consumeContentLengthMessages() { for (;;) { const headerEnd = inputBuffer.indexOf('\r\n\r\n') const altHeaderEnd = headerEnd < 0 ? inputBuffer.indexOf('\n\n') : -1 const boundary = headerEnd >= 0 ? headerEnd : altHeaderEnd if (boundary < 0) return const separatorLength = headerEnd >= 0 ? 4 : 2 const header = inputBuffer.subarray(0, boundary).toString('utf8') const match = /content-length:\s*(\d+)/i.exec(header) if (!match) { inputBuffer = inputBuffer.subarray(boundary + separatorLength) writeMessage(jsonRpcError(null, -32600, 'content_length_missing')) continue } const contentLength = Number(match[1]) const bodyStart = boundary + separatorLength const bodyEnd = bodyStart + contentLength if (inputBuffer.length < bodyEnd) return const body = inputBuffer.subarray(bodyStart, bodyEnd).toString('utf8') inputBuffer = inputBuffer.subarray(bodyEnd) const message = parseJsonMessage(body) if (message) void handleRequest(message) } } function consumeInputBuffer() { if (!inputBuffer.length) return if (!transportMode) { const trimmedStart = inputBuffer.toString('utf8', 0, Math.min(inputBuffer.length, 32)).trimStart() if (trimmedStart.startsWith('Content-Length:') || trimmedStart.toLowerCase().startsWith('content-length:')) { transportMode = 'content-length' } else if (trimmedStart.startsWith('{')) { transportMode = 'json-lines' } else if (inputBuffer.length < 32) { return } else { transportMode = 'json-lines' } } if (transportMode === 'content-length') consumeContentLengthMessages() else consumeJsonLineMessages() } process.stdin.on('data', (chunk) => { inputBuffer = Buffer.concat([inputBuffer, Buffer.from(chunk)]) consumeInputBuffer() }) process.stdin.on('end', () => { if (transportMode === 'json-lines') { const line = inputBuffer.toString('utf8').trim() inputBuffer = Buffer.alloc(0) if (!line) return const message = parseJsonMessage(line) if (message) void handleRequest(message) } }) if (process.env.NDC_AGENT_MCP_DEBUG_JSONL === '1') { process.stdin.setEncoding('utf8') } process.on('uncaughtException', (error) => { writeMessage(jsonRpcError(null, -32000, error?.message || error)) }) process.on('unhandledRejection', (reason) => { writeMessage(jsonRpcError(null, -32000, reason?.message || reason)) }) if (process.stdin.isTTY) { process.stderr.write('ndc-agent-mcp expects JSON-RPC messages on stdin.\n') } '@ Set-Content -Path $workerPath -Value $workerCode -Encoding UTF8 Set-Content -Path $ndcAgentMcpServerPath -Value $ndcAgentMcpServerCode -Encoding UTF8 $startCode = @" `$ErrorActionPreference = 'Stop' `$env:AI_BRIDGE_HOST = '0.0.0.0' `$env:AI_BRIDGE_PORT = '$Port' `$env:AI_BRIDGE_CODEX_CWD = $(ConvertTo-PsSingleQuoted $WorkspacePath) `$env:AI_BRIDGE_CODEX_BIN = 'codex' `$env:AI_BRIDGE_CODEX_ARGS = '["exec","--json","--skip-git-repo-check","-c","model_reasoning_summary=detailed","-c","model_supports_reasoning_summaries=true","-c","use_experimental_reasoning_summary=true","-c","hide_agent_reasoning=false","-c","show_raw_agent_reasoning=false","-"]' `$env:AI_BRIDGE_ALLOW_ORIGIN = '*' `$env:AI_BRIDGE_HUB_URL = $(ConvertTo-PsSingleQuoted $HubUrl) `$env:AI_BRIDGE_HUB_URLS = $(ConvertTo-PsSingleQuoted $hubUrlsText) `$env:AI_BRIDGE_PAIRING_CODE = $(ConvertTo-PsSingleQuoted $PairingCode) `$env:AI_BRIDGE_MACHINE_NAME = $(ConvertTo-PsSingleQuoted $resolvedMachineName) `$env:AI_BRIDGE_ACTIVE_MIRROR_DIR = $(ConvertTo-PsSingleQuoted $activeMirror) `$env:AI_BRIDGE_ACTIVE_MIRROR_OPEN_IDE = '1' `$env:Path = $(ConvertTo-PsSingleQuoted $nodeDir) + ';' + $(ConvertTo-PsSingleQuoted $npmPrefix) + ';' + `$env:Path Set-Location $(ConvertTo-PsSingleQuoted $Root) & $(ConvertTo-PsSingleQuoted $nodeSource) $(ConvertTo-PsSingleQuoted $workerPath) *>> $(ConvertTo-PsSingleQuoted $logPath) "@ Set-Content -Path $startScript -Value $startCode -Encoding UTF8 $watchdogCode = @" `$ErrorActionPreference = 'SilentlyContinue' `$startScript = $(ConvertTo-PsSingleQuoted $startScript) `$logPath = $(ConvertTo-PsSingleQuoted $logPath) `$healthUrl = $(ConvertTo-PsSingleQuoted "http://127.0.0.1:$Port$HealthPath") `$healthy = `$false try { `$request = [System.Net.HttpWebRequest]::Create(`$healthUrl) `$request.Method = 'GET' `$request.Proxy = `$null `$request.Timeout = 2000 `$request.ReadWriteTimeout = 2000 `$response = `$request.GetResponse() try { `$stream = `$response.GetResponseStream() `$reader = New-Object System.IO.StreamReader(`$stream) `$body = `$reader.ReadToEnd() `$healthy = `$response.StatusCode -eq 200 -and `$body -match '"ok"\s*:\s*true' } finally { `$response.Close() } } catch {} if (-not `$healthy) { try { Add-Content -Path `$logPath -Value ("[{0}] watchdog: health probe failed; starting bridge." -f (Get-Date).ToString('s')) -Encoding UTF8 } catch {} Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile', '-WindowStyle', 'Hidden', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', `$startScript) -WindowStyle Hidden | Out-Null } "@ Set-Content -Path $watchdogScript -Value $watchdogCode -Encoding UTF8 $config = [ordered]@{ service = 'NDC AI Workspace Bridge' port = $Port installRoot = $Root workspace = $WorkspacePath codexHome = $bridgeCodexHome worker = $workerPath startScript = $startScript watchdogScript = $watchdogScript watchdogEnabled = [bool]$EnableWatchdog taskName = $TaskName watchdogTaskName = "$TaskName Watchdog" healthPath = $HealthPath conversationPath = $ConversationPath activeMirror = $activeMirror } $config | ConvertTo-Json -Depth 5 | Set-Content -Path $configPath -Encoding UTF8 Write-Ok "Installed to $Root" return @{ WorkerPath = $workerPath StartScript = $startScript WatchdogScript = $watchdogScript ConfigPath = $configPath Logs = $logs } } function Register-BridgeAutostart { param( [string]$StartScript, [string]$WatchdogScript ) $watchdogTaskName = "$TaskName Watchdog" if (-not $EnableWatchdog) { try { Stop-ScheduledTask -TaskName $watchdogTaskName -ErrorAction SilentlyContinue } catch {} try { Unregister-ScheduledTask -TaskName $watchdogTaskName -Confirm:$false -ErrorAction SilentlyContinue Write-Ok "Watchdog task disabled: $watchdogTaskName" } catch {} } if ($NoAutostart) { Write-Warn 'Autostart skipped by flag.' return } Write-Step 'Registering Windows autostart' $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -WindowStyle Hidden -NonInteractive -ExecutionPolicy Bypass -File `"$StartScript`"" $triggers = @() $triggers += New-ScheduledTaskTrigger -AtLogOn $triggers += New-ScheduledTaskTrigger -AtStartup $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Days 3650) -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -MultipleInstances IgnoreNew Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $triggers -Settings $settings -Description 'NDC AI Workspace Bridge for NODE.DC AI Workspace' -RunLevel Highest -Force | Out-Null Write-Ok "Scheduled task registered: $TaskName" if ($EnableWatchdog -and $WatchdogScript) { $watchdogAction = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -WindowStyle Hidden -NonInteractive -ExecutionPolicy Bypass -File `"$WatchdogScript`"" $watchdogTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(2) -RepetitionInterval (New-TimeSpan -Minutes 5) -RepetitionDuration (New-TimeSpan -Days 3650) $watchdogSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5) -MultipleInstances IgnoreNew Register-ScheduledTask -TaskName $watchdogTaskName -Action $watchdogAction -Trigger $watchdogTrigger -Settings $watchdogSettings -Description 'NDC AI Workspace Bridge watchdog' -RunLevel Highest -Force | Out-Null Write-Ok "Watchdog task registered: $watchdogTaskName" } } function Start-BridgeNow { param([string]$StartScript) Write-Step 'Starting bridge now' Stop-BridgeNow Start-Process -FilePath 'powershell.exe' -ArgumentList "-NoProfile -WindowStyle Hidden -NonInteractive -ExecutionPolicy Bypass -File `"$StartScript`"" -WindowStyle Hidden | Out-Null Write-Ok 'Bridge process started.' } function Stop-BridgeNow { try { Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue } catch {} try { Stop-ScheduledTask -TaskName "$TaskName Watchdog" -ErrorAction SilentlyContinue } catch {} try { $escapedRoot = [Regex]::Escape($InstallRoot) $processes = Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'worker\.mjs' -and ($_.CommandLine -match $escapedRoot -or $_.CommandLine -match 'AIWorkspaceBridge') } foreach ($process in $processes) { try { Stop-Process -Id $process.ProcessId -Force -ErrorAction Stop Write-Ok "Stopped existing bridge process: $($process.ProcessId)" } catch {} } } catch {} } function Get-BridgeLogTail { $logPath = Join-Path $InstallRoot 'logs\bridge.log' if (-not (Test-Path $logPath)) { return '' } try { return ((Get-Content -Path $logPath -Tail 80 -ErrorAction Stop) -join [Environment]::NewLine) } catch { return '' } } function Get-PortOwnerReport { param([int]$LocalPort = $Port) try { $listeners = Get-NetTCPConnection -LocalPort $LocalPort -State Listen -ErrorAction SilentlyContinue | Sort-Object OwningProcess, LocalAddress -Unique if (-not $listeners) { return '' } $lines = @() foreach ($listener in $listeners) { $processId = [int]$listener.OwningProcess $process = Get-CimInstance Win32_Process -Filter "ProcessId=$processId" -ErrorAction SilentlyContinue if ($process) { $commandLine = ([string]$process.CommandLine).Trim() if ($commandLine.Length -gt 500) { $commandLine = $commandLine.Substring(0, 500) + '...' } $lines += ("{0}:{1} PID {2} {3} {4}" -f $listener.LocalAddress, $listener.LocalPort, $processId, $process.Name, $commandLine) } else { $lines += ("{0}:{1} PID {2}" -f $listener.LocalAddress, $listener.LocalPort, $processId) } } return ($lines -join [Environment]::NewLine) } catch { return '' } } function Test-BridgeProcess { param([object]$Process) if (-not $Process) { return $false } $commandLine = [string]$Process.CommandLine if (-not $commandLine) { return $false } try { $escapedRoot = [Regex]::Escape($InstallRoot) return ( $commandLine -match 'worker\.mjs' -and ($commandLine -match $escapedRoot -or $commandLine -match 'AIWorkspaceBridge') ) } catch { return ($commandLine -match 'worker\.mjs' -and $commandLine -match 'AIWorkspaceBridge') } } function Get-BlockingPortOwners { param([int]$LocalPort) try { $listeners = Get-NetTCPConnection -LocalPort $LocalPort -State Listen -ErrorAction SilentlyContinue | Sort-Object OwningProcess, LocalAddress -Unique if (-not $listeners) { return @() } $blocking = @() foreach ($listener in $listeners) { $processId = [int]$listener.OwningProcess $process = Get-CimInstance Win32_Process -Filter "ProcessId=$processId" -ErrorAction SilentlyContinue if (Test-BridgeProcess $process) { continue } $blocking += [pscustomobject]@{ LocalAddress = $listener.LocalAddress LocalPort = $listener.LocalPort ProcessId = $processId Name = if ($process) { $process.Name } else { '' } CommandLine = if ($process) { [string]$process.CommandLine } else { '' } } } return @($blocking) } catch { return @() } } function Resolve-BridgePort { $requested = [int]$Port for ($candidate = $requested; $candidate -le ($requested + 20); $candidate++) { $blocking = @(Get-BlockingPortOwners -LocalPort $candidate) if ($blocking.Count -eq 0) { if ($candidate -ne $requested) { Write-Warn "TCP $requested is occupied by another service; using TCP $candidate for local bridge health." } return $candidate } } $report = Get-PortOwnerReport -LocalPort $requested if ($report) { throw "No free bridge port near $requested. Current listener on TCP ${requested}:`n$report" } throw "No free bridge port near $requested." } function Try-StartBridgeViaTask { if ($NoAutostart) { return } try { Start-ScheduledTask -TaskName $TaskName Write-Ok 'Autostart task started for validation.' } catch { Write-Warn "Could not start scheduled task: $($_.Exception.Message)" } } function Ensure-Firewall { if ($NoFirewall) { Write-Warn 'Firewall rule skipped by flag.' return } Write-Step 'Configuring Windows Firewall' $displayName = "NDC AI Workspace Bridge $Port" $existing = Get-NetFirewallRule -DisplayName $displayName -ErrorAction SilentlyContinue if ($existing) { $existing | Set-NetFirewallRule -Enabled True -Profile Domain,Private,Public -Direction Inbound -Action Allow Write-Ok "Firewall rule updated for TCP ${Port}: $displayName" return } New-NetFirewallRule -DisplayName $displayName -Direction Inbound -Action Allow -Protocol TCP -LocalPort $Port -Profile Domain,Private,Public | Out-Null Write-Ok "Firewall rule added for TCP $Port" } function Normalize-PairingCode { param([string]$Value) if (-not $Value) { return '' } return (($Value -replace '[^A-Za-z0-9]', '').ToUpperInvariant()) } function Get-LocalBridgeHealth { $healthUrl = "http://127.0.0.1:$Port$HealthPath" try { $request = [System.Net.HttpWebRequest]::Create($healthUrl) $request.Method = 'GET' $request.Proxy = $null $request.Timeout = 2000 $request.ReadWriteTimeout = 2000 $response = $request.GetResponse() try { $stream = $response.GetResponseStream() $reader = New-Object System.IO.StreamReader($stream) $body = $reader.ReadToEnd() $data = $null try { $data = $body | ConvertFrom-Json } catch {} return [pscustomobject]@{ Ok = ($response.StatusCode -eq 200 -and $null -ne $data -and $data.ok -eq $true) Url = $healthUrl StatusCode = [int]$response.StatusCode Body = $body Data = $data Error = '' } } finally { $response.Close() } } catch { $statusCode = 0 $body = '' $response = $_.Exception.Response if ($response) { try { $statusCode = [int]$response.StatusCode $stream = $response.GetResponseStream() if ($stream) { $reader = New-Object System.IO.StreamReader($stream) $body = $reader.ReadToEnd() } } catch {} try { $response.Close() } catch {} } return [pscustomobject]@{ Ok = $false Url = $healthUrl StatusCode = $statusCode Body = $body Data = $null Error = $_.Exception.Message } } } function Wait-Health { Write-Step 'Checking bridge health' $lastError = '' $lastStatusCode = 0 $lastBody = '' for ($i = 0; $i -lt 30; $i++) { $health = Get-LocalBridgeHealth if ($health.Ok) { Write-Ok "Health OK: $($health.Url)" return $health.Data } $lastError = $health.Error $lastStatusCode = [int]$health.StatusCode $lastBody = [string]$health.Body Start-Sleep -Seconds 1 } Write-Warn "Bridge did not answer with JSON health yet: http://127.0.0.1:$Port$HealthPath" if ($lastStatusCode -gt 0) { Write-Warn "Last health HTTP status: $lastStatusCode" } if ($lastError) { Write-Warn "Last health error: $lastError" } if ($lastBody) { $bodyPreview = ($lastBody -replace '\s+', ' ').Trim() if ($bodyPreview.Length -gt 500) { $bodyPreview = $bodyPreview.Substring(0, 500) + '...' } Write-Warn "Last health body: $bodyPreview" } $portOwner = Get-PortOwnerReport if ($portOwner) { Write-Host "Current listener on TCP ${Port}:" -ForegroundColor Yellow Write-Host $portOwner -ForegroundColor Yellow } else { Write-Warn "No listener detected on TCP $Port." } return $null } function Wait-HubConnection { param([object]$InitialHealth) if (-not $HubUrl -or -not $PairingCode) { return $true } Write-Step 'Checking Cloud Hub connection' $expectedPairing = Normalize-PairingCode $PairingCode $lastHubError = '' $lastHubUrl = '' for ($i = 0; $i -lt 60; $i++) { $health = if ($i -eq 0 -and $InitialHealth) { $InitialHealth } else { (Get-LocalBridgeHealth).Data } if ($health) { $hub = $health.hub $hubConnected = ($health.hubConnected -eq $true) if ($hub) { $hubConnected = $hubConnected -or ($hub.connected -eq $true) $lastHubError = [string]$hub.lastError $lastHubUrl = [string]$hub.url $actualPairing = Normalize-PairingCode ([string]$hub.pairingCode) if ($hubConnected -and $actualPairing -eq $expectedPairing) { if ($lastHubUrl) { Write-Ok "Cloud Hub connected: $lastHubUrl" } else { Write-Ok 'Cloud Hub connected.' } return $true } } } Start-Sleep -Seconds 1 } Write-Warn 'Cloud Hub connection was not confirmed.' if ($lastHubError) { Write-Warn "Last Hub error: $lastHubError" } $hubUrlCandidates = @(Get-HubUrlCandidates) if ($hubUrlCandidates.Count -gt 0) { Write-Host "Hub candidates:" -ForegroundColor Yellow foreach ($candidate in $hubUrlCandidates) { Write-Host " $candidate" -ForegroundColor Yellow } } return $false } function Get-BridgeIps { $ips = @() try { $ips = Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' } | Sort-Object InterfaceMetric | Select-Object -ExpandProperty IPAddress -Unique } catch {} if (-not $ips -or $ips.Count -eq 0) { $ips = [System.Net.Dns]::GetHostAddresses($env:COMPUTERNAME) | Where-Object { $_.AddressFamily -eq 'InterNetwork' -and $_.IPAddressToString -notlike '127.*' } | ForEach-Object { $_.IPAddressToString } } return @($ips) } function Print-Result { param( [string]$Root, [string]$WorkspacePath, [bool]$Healthy, [bool]$HubHealthy ) $ips = Get-BridgeIps $endpoint = if ($ips.Count -gt 0) { "http://$($ips[0]):$Port" } else { "http://:$Port" } $hubUrlCandidates = @(Get-HubUrlCandidates) Write-Step 'Done' Write-Host "AI Workspace settings:" Write-Host " Type: Codex worker" Write-Host " Model: codex" if ($HubUrl -and $PairingCode) { Write-Host " Connection: Cloud Hub" -ForegroundColor Green if ($HubHealthy) { Write-Host " Hub connection: confirmed" -ForegroundColor Green } else { Write-Host " Hub connection: not confirmed" -ForegroundColor Yellow } } else { Write-Host " Bridge endpoint: $endpoint" -ForegroundColor Green } Write-Host "" Write-Host "Local health:" Write-Host " http://127.0.0.1:$Port$HealthPath" Write-Host "" Write-Host "Install root:" Write-Host " $Root" Write-Host "Workspace:" Write-Host " $WorkspacePath" Write-Host "Autostart task:" Write-Host " $TaskName" if ($hubUrlCandidates.Count -gt 0 -and $PairingCode) { Write-Host "Hub candidates:" foreach ($candidate in $hubUrlCandidates) { Write-Host " $candidate" } Write-Host "Pairing code:" Write-Host " $PairingCode" -ForegroundColor Green } if ($ips.Count -gt 1) { Write-Host "" Write-Host "Other possible endpoints:" foreach ($ip in $ips | Select-Object -Skip 1) { Write-Host " http://${ip}:$Port" } } $copyText = @" Type: Codex worker Model: codex Connection: $(if ($HubUrl -and $PairingCode) { 'Cloud Hub' } else { $endpoint }) Hub connection: $(if ($HubUrl -and $PairingCode) { if ($HubHealthy) { 'confirmed' } else { 'not confirmed' } } else { 'not used' }) Health: http://127.0.0.1:$Port$HealthPath Workspace: $WorkspacePath Hub candidates: $($hubUrlCandidates -join ', ') Pairing code: $PairingCode "@ Write-InstallResult $copyText try { Set-Clipboard -Value $copyText Write-Ok 'Connection details copied to clipboard.' } catch {} if (-not $Healthy) { Write-Warn 'If Engine cannot connect, check Windows Firewall profile and bridge.log in the install root.' $logTail = Get-BridgeLogTail if ($logTail) { Write-Host "" Write-Host "bridge.log tail:" -ForegroundColor Yellow Write-Host $logTail try { Add-Content -Path (Get-ResultPath) -Value "`nbridge.log tail:`n$logTail" -Encoding UTF8 } catch {} } } } function Main { if (-not $Confirmed) { Write-Host "" Write-Host "NDC AI Workspace Bridge will install or update Node.js, Codex CLI, a local bridge service, autostart, and firewall access." Write-Host "Target port: $Port" } Request-Elevation $workspacePath = Get-WorkspacePath Ensure-Node Ensure-Codex Ensure-CodexLogin Repair-CodexMcpConfig 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 }