diff --git a/services/ai-workspace-assistant/src/assets/ai-workspace-npm/README.md b/services/ai-workspace-assistant/src/assets/ai-workspace-npm/README.md new file mode 100644 index 0000000..a6eca4a --- /dev/null +++ b/services/ai-workspace-assistant/src/assets/ai-workspace-npm/README.md @@ -0,0 +1,22 @@ +# NODE.DC AI Workspace Bridge + +Installs the remote Codex worker used by NODE.DC AI Workspace. + +Registry form: + +```sh +npx --yes @nodedc/ai-workspace-bridge setup --gateway https://ai-hub.nodedc.ru +``` + +Commands: + +```sh +ai-workspace-bridge setup [--gateway ] [--install-root ] [--workspace ] [--port ] +ai-workspace-bridge status [--install-root ] +ai-workspace-bridge doctor [--install-root ] +ai-workspace-bridge start [--install-root ] +ai-workspace-bridge stop [--install-root ] +ai-workspace-bridge logs [--install-root ] +``` + +The setup code is one-time and short-lived. The CLI does not print the redeemed pairing code or MCP tokens. diff --git a/services/ai-workspace-assistant/src/assets/ai-workspace-npm/assets/ndcAgentMcpServer.mjs b/services/ai-workspace-assistant/src/assets/ai-workspace-npm/assets/ndcAgentMcpServer.mjs new file mode 100755 index 0000000..e26395f --- /dev/null +++ b/services/ai-workspace-assistant/src/assets/ai-workspace-npm/assets/ndcAgentMcpServer.mjs @@ -0,0 +1,1017 @@ +#!/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 '' + return raw.startsWith('n8n-nodes-base.') ? raw : `n8n-nodes-base.${raw}` +} + +function cleanWebhookSegment(value, fallback) { + const out = cleanString(value, 240) + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + return out || fallback +} + +function makeNdcWebhookPath(workflowId, nodeId) { + return `ndc/${cleanWebhookSegment(workflowId, 'workflow')}/${cleanWebhookSegment(nodeId, 'agent')}/run` +} + +function defaultSubworkflowGraph(workflowId = context.workflowId, nodeId = context.agentNodeId) { + return { + version: 1, + nodes: [{ + id: 'webhook-trigger', + type: 'n8nOp', + position: { x: 0, y: 0 }, + data: { + title: 'Webhook Trigger', + n8n: { + parameters: { + httpMethod: 'POST', + path: makeNdcWebhookPath(workflowId, nodeId), + responseMode: 'onReceived', + options: {}, + }, + type: 'n8n-nodes-base.webhook', + typeVersion: 2.1, + position: [0, 0], + id: 'webhook-trigger', + name: 'Webhook Trigger', + }, + w: 220, + h: 120, + n8nTypeLabel: 'Webhook', + n8nShowId: false, + n8nOutputLabels: [], + }, + }], + edges: [], + source: { + createdBy: 'ndc-agent-mcp', + initialized: 'default-webhook', + }, + } +} + +function normalizeGraphResult(value, workflowId = context.workflowId, nodeId = context.agentNodeId) { + const graph = value && typeof value === 'object' ? { ...value } : defaultSubworkflowGraph(workflowId, nodeId) + graph.version = Number(graph.version || 1) || 1 + graph.nodes = Array.isArray(graph.nodes) && graph.nodes.length ? graph.nodes : defaultSubworkflowGraph(workflowId, nodeId).nodes + graph.edges = Array.isArray(graph.edges) ? graph.edges : [] + return graph +} + +function versionSummary(version) { + if (Array.isArray(version)) return version + if (version === undefined || version === null) return [] + return [version] +} + +function compactNodeDefinition(node, maxProperties = 80) { + const properties = Array.isArray(node?.properties) ? node.properties.slice(0, maxProperties) : [] + return { + name: node?.name || '', + fullType: fullNodeType(node?.name || ''), + displayName: node?.displayName || node?.name || '', + description: node?.description || '', + group: node?.group || [], + versions: versionSummary(node?.version), + defaults: node?.defaults || {}, + inputs: node?.inputs || [], + outputs: node?.outputs || [], + credentials: node?.credentials || [], + properties, + propertiesTruncated: Array.isArray(node?.properties) && node.properties.length > properties.length, + } +} + +async function engineFetch(pathname, options = {}) { + const urls = engineApiUrls(pathname) + const failures = [] + for (const url of urls) { + let res = null + try { + res = await fetch(url, { + ...options, + redirect: 'manual', + signal: options.signal || AbortSignal.timeout(ENGINE_FETCH_TIMEOUT_MS), + headers: { + Accept: 'application/json', + ...(options.body ? { 'Content-Type': 'application/json' } : {}), + ...(options.headers || {}), + }, + }) + } catch (error) { + failures.push(`${url} -> ${fetchFailureText(error)}`) + continue + } + const text = await res.text().catch(() => '') + let json = null + try { json = text ? JSON.parse(text) : null } catch {} + if (!res.ok || json?.ok === false) { + const error = new Error(json?.message || json?.error || `engine_http_${res.status}`) + error.status = res.status + error.payload = { ...(json && typeof json === 'object' ? json : {}), url } + throw error + } + if (!json || typeof json !== 'object') { + const error = new Error(`engine_non_json_response:${res.status}`) + error.status = 502 + error.payload = { + url, + status: res.status, + contentType: res.headers.get('content-type') || '', + preview: cleanString(text, 200), + } + throw error + } + return json + } + const error = new Error(`engine_fetch_failed:${failures.join(' | ') || 'no_engine_api_urls'}`) + error.payload = { attempts: failures, apiBaseCandidates: engineApiBaseCandidates(), pathname } + throw error +} + +function targetFromArgs(args = {}) { + const workflowId = cleanString(args.workflowId || context.workflowId) + const nodeId = cleanString(args.nodeId || args.agentNodeId || context.agentNodeId) + if (!workflowId || !nodeId) throw new Error('workflowId_and_nodeId_required') + return { workflowId, nodeId } +} + +async function handleGetContext() { + const n8nMcpVersion = await loadN8nMcpVersion() + return { + ok: true, + ...context, + apiBaseCandidates: engineApiBaseCandidates(), + n8nMcpVersion, + 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 || '', + fullType: fullNodeType(node?.name || ''), + displayName: node?.displayName || node?.name || '', + description: node?.description || '', + group: node?.group || [], + versions: versionSummary(node?.version), + })) + return { ok: true, query, count: matches.length, nodes: matches } +} + +async function handleGetNodeDefinition(args) { + const nodeType = normalizeTypeName(args.nodeType || args.type || args.name) + if (!nodeType) throw new Error('nodeType_required') + const catalog = await loadNodeCatalog(args.schemaVersion) + const node = catalog.find((item) => ( + String(item?.name || '') === nodeType || + fullNodeType(item?.name || '') === cleanString(args.nodeType || args.type || args.name) + )) + if (!node) throw new Error(`node_not_found:${nodeType}`) + return { ok: true, node: compactNodeDefinition(node, Number(args.maxProperties || 80) || 80) } +} + +async function handleValidateSubworkflow(args) { + const target = (() => { + try { return targetFromArgs(args) } catch { return { workflowId: context.workflowId, nodeId: context.agentNodeId } } + })() + const graph = args.graph && typeof args.graph === 'object' + ? args.graph + : normalizeGraphResult((await handleGetSubworkflow(args)).graph, target.workflowId, target.nodeId) + const nodes = Array.isArray(graph?.nodes) ? graph.nodes : [] + const edges = Array.isArray(graph?.edges) ? graph.edges : [] + const nodeIds = new Set() + const issues = [] + nodes.forEach((node, index) => { + const id = cleanString(node?.id) + if (!id) issues.push({ level: 'error', code: 'node_id_missing', index }) + if (id && nodeIds.has(id)) issues.push({ level: 'error', code: 'node_id_duplicate', nodeId: id }) + if (id) nodeIds.add(id) + const typeName = cleanString(node?.data?.n8n?.type) + if (!typeName) issues.push({ level: 'warn', code: 'node_type_missing', nodeId: id }) + }) + edges.forEach((edge, index) => { + const source = cleanString(edge?.source) + const target = cleanString(edge?.target) + if (!source || !target) issues.push({ level: 'error', code: 'edge_endpoint_missing', index }) + if (source && !nodeIds.has(source)) issues.push({ level: 'error', code: 'edge_source_missing', edgeId: edge?.id, source }) + if (target && !nodeIds.has(target)) issues.push({ level: 'error', code: 'edge_target_missing', edgeId: edge?.id, target }) + }) + return { + ok: !issues.some((issue) => issue.level === 'error'), + nodesCount: nodes.length, + edgesCount: edges.length, + issues, + } +} + +const tools = [ + { + name: 'ndc_get_context', + description: 'Return selected NDC Agent Core target context and safety contract.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + { + name: 'ndc_get_subworkflow', + description: 'Read the selected Engine second-level workflow graph from dc.subworkflow.json.', + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string' }, + nodeId: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + { + name: 'ndc_apply_subworkflow_patch', + description: 'Apply safe graph patch operations to the selected Engine second-level workflow.', + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string' }, + nodeId: { type: 'string' }, + intent: { type: 'string' }, + expectedUpdatedAt: { type: 'string' }, + operations: { + type: 'array', + items: { type: 'object', additionalProperties: true }, + }, + }, + required: ['operations'], + additionalProperties: false, + }, + }, + { + name: 'ndc_search_nodes', + description: 'Search NDC node definitions from the pinned v2.3.2 schema catalog.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string' }, + limit: { type: 'number' }, + schemaVersion: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + { + name: 'ndc_get_node_definition', + description: 'Return a compact NDC node definition, including properties, credentials, inputs, and outputs.', + inputSchema: { + type: 'object', + properties: { + nodeType: { type: 'string' }, + schemaVersion: { type: 'string' }, + maxProperties: { type: 'number' }, + }, + required: ['nodeType'], + additionalProperties: false, + }, + }, + { + name: 'ndc_validate_subworkflow', + description: 'Validate a second-level workflow graph or the selected saved graph for basic structural issues.', + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string' }, + nodeId: { type: 'string' }, + graph: { type: 'object', additionalProperties: true }, + }, + additionalProperties: false, + }, + }, + { + 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') +} diff --git a/services/ai-workspace-assistant/src/assets/ai-workspace-npm/assets/worker.mjs b/services/ai-workspace-assistant/src/assets/ai-workspace-npm/assets/worker.mjs new file mode 100755 index 0000000..1761ce3 --- /dev/null +++ b/services/ai-workspace-assistant/src/assets/ai-workspace-npm/assets/worker.mjs @@ -0,0 +1,2255 @@ +#!/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') +} + +function truncateText(value, max = 1400) { + const text = String(value || '').trim() + return text.length > max ? `${text.slice(0, max).trim()}...` : text +} + +function markdownText(value, max = 12000) { + return truncateText(String(value || '').replace(/\r\n/g, '\n'), max) +} + +function sanitizeCodexErrorText(value, max = 1400) { + const text = String(value || '').replace(/\r\n/g, '\n').trim() + if (!text) return '' + const statusMatch = text.match(/\b(?:HTTP error:\s*)?((?:401|403|429|5\d\d)\s+[A-Za-z][A-Za-z\s-]*)\b/i) + const urlMatch = text.match(/\burl:\s*((?:https?|wss):\/\/[^\s,]+)/i) + || text.match(/\b((?:https?|wss):\/\/chatgpt\.com\/[^\s,]+)/i) + const rayMatch = text.match(/\bcf-ray:\s*([A-Za-z0-9-]+)/i) + || text.match(/\bRay ID:?\s*([A-Za-z0-9-]+)/i) + if (/chatgpt\.com\/backend-api\/codex|Unable to load site|cf-ray|Cloudflare|403 Forbidden/i.test(text)) { + return [ + `Codex backend rejected request${statusMatch ? `: ${statusMatch[1].trim()}` : ''}.`, + urlMatch ? `url: ${urlMatch[1].replace(/[)>]+$/, '')}` : '', + rayMatch ? `cf-ray: ${rayMatch[1]}` : '', + ].filter(Boolean).join(' ') + } + return truncateText(text, max) +} + +function eventText(event) { + return [ + event?.message, + event?.text, + event?.command ? `$ ${event.command}` : '', + ].map((item) => String(item || '').trim()).filter(Boolean).join('\n') +} + +function renderMirrorSignal(label, value) { + const text = markdownText(value, 1800) + return text ? `- ${label}: ${text.replace(/\n/g, '\n ')}` : '' +} + +function renderActiveMirrorMarkdown(state) { + const activity = state.events.length + ? state.events.map((event) => { + const text = markdownText(event.text, 2200) + const inline = text.includes('\n') ? `\n\n${text}\n` : ` ${text}` + return `- ${event.at} ${event.kind}:${inline}` + }).join('\n') + : '- waiting for Codex events' + const signal = [ + renderMirrorSignal('Reasoning', state.currentReasoning), + renderMirrorSignal('Command', state.currentCommand), + renderMirrorSignal('Files', state.currentFiles), + renderMirrorSignal('Tool', state.currentTool), + renderMirrorSignal('Todo', state.currentTodo), + ].filter(Boolean).join('\n') || '- waiting for first Codex signal' + return [ + '# NDC AI Workspace Active Run', + '', + `Status: ${state.status}`, + `Machine: ${state.machineName}`, + `Workspace: ${state.cwd}`, + `Mode: ${state.modeTitle || state.modeId || 'unknown'}`, + `Thread: ${state.threadTitle || state.threadId || 'unknown'}`, + `Request: ${state.requestId}`, + `Started: ${state.startedAt}`, + `Updated: ${state.updatedAt}`, + state.finishedAt ? `Finished: ${state.finishedAt}` : '', + '', + '## Active Mirror', + '', + `Current file: ${state.currentFile}`, + `Events file: ${state.eventsFile}`, + '', + '## User Message', + '', + markdownText(state.userMessage || ''), + '', + '## Current Signal', + '', + signal, + '', + '## Live Activity', + '', + activity, + '', + '## Last Assistant Output', + '', + markdownText(state.assistantText || 'No assistant output yet.'), + '', + ].filter((line) => line !== '').join('\n') +} + +function openActiveMirrorInIde(filePath) { + if (!ACTIVE_MIRROR_OPEN_IDE || !ACTIVE_MIRROR_IDE_BIN || !filePath) return + try { + const child = spawn(ACTIVE_MIRROR_IDE_BIN, ['-r', filePath], { + detached: true, + shell: process.platform === 'win32', + stdio: 'ignore', + windowsHide: true, + }) + child.unref() + } catch {} +} + +function activeMirrorPathsForCwd(cwd) { + const workspace = resolveWorkspacePath(cwd || CODEX_CWD) + const mirrorDir = workspace === CODEX_CWD + ? ACTIVE_MIRROR_DIR + : path.join(workspace, '.nodedc', 'ai-workspace') + return { + mirrorDir, + currentFile: path.join(mirrorDir, 'current.md'), + eventsFile: path.join(mirrorDir, 'events.jsonl'), + } +} + +async function hasGitMarker(dir) { + try { + await fs.stat(path.join(dir, '.git')) + return true + } catch { + return false + } +} + +async function addWorkspace(map, workspacePath, source, patch = {}) { + const cleanPath = String(workspacePath || '').trim() + if (!cleanPath) return + let stat = null + try { + stat = await fs.stat(cleanPath) + } catch { + return + } + if (!stat.isDirectory()) return + const id = workspaceId(cleanPath) + const previous = map.get(id) || {} + map.set(id, { + id, + path: cleanPath, + title: patch.title || previous.title || workspaceTitle(cleanPath), + kind: patch.kind || previous.kind || (await hasGitMarker(cleanPath) ? 'git' : 'folder'), + source: Array.from(new Set([...(previous.source ? String(previous.source).split(', ') : []), source].filter(Boolean))).join(', '), + lastUsedAt: patch.lastUsedAt || previous.lastUsedAt || null, + sessionCount: Number(previous.sessionCount || 0) + Number(patch.sessionCount || 0), + }) +} + +async function discoverWorkspaces() { + const map = new Map() + await addWorkspace(map, CODEX_CWD, 'configured', { title: `${workspaceTitle(CODEX_CWD)} (default)` }) + const conversations = await readConversations() + for (const conversation of conversations) { + if (!conversation.workspacePath) continue + await addWorkspace(map, conversation.workspacePath, 'codex-session', { + lastUsedAt: conversation.workspaceUpdatedAt || conversation.updatedAt || null, + sessionCount: 1, + }) + } + const workspaces = Array.from(map.values()) + .sort((a, b) => { + if (a.path === CODEX_CWD) return -1 + if (b.path === CODEX_CWD) return 1 + const byDate = String(b.lastUsedAt || '').localeCompare(String(a.lastUsedAt || '')) + if (byDate) return byDate + return String(a.title || '').localeCompare(String(b.title || '')) + }) + .slice(0, MAX_WORKSPACES) + return { + ok: true, + cwd: CODEX_CWD, + workspaces, + } +} + +function createActiveMirror(command, payload = {}, meta = {}) { + const context = payload?.context && typeof payload.context === 'object' ? payload.context : {} + if (!ACTIVE_MIRROR_ENABLED || command !== 'message' || context.remoteControlMode !== 'active') return null + const cwd = String(meta.cwd || CODEX_CWD) + const displayMessage = String(payload.displayMessage || payload.publicUserMessage || payload.message || '') + const { mirrorDir, currentFile, eventsFile } = activeMirrorPathsForCwd(cwd) + const startedAt = new Date().toISOString() + const state = { + requestId: String(meta.requestId || payload.threadId || `local-${Date.now()}`), + threadId: String(payload.threadId || ''), + threadTitle: String(payload.threadTitle || ''), + userMessage: displayMessage, + modeId: String(context.modeId || ''), + modeTitle: String(context.modeTitle || ''), + machineName: MACHINE_NAME, + cwd, + status: 'running', + startedAt, + updatedAt: startedAt, + finishedAt: '', + assistantText: '', + currentFile, + eventsFile, + currentReasoning: '', + currentCommand: '', + currentFiles: '', + currentTool: '', + currentTodo: '', + events: [], + ideOpened: false, + } + let chain = Promise.resolve() + + const write = (row) => { + chain = chain.then(async () => { + state.updatedAt = new Date().toISOString() + await fs.mkdir(mirrorDir, { recursive: true }) + if (row) { + await fs.appendFile(eventsFile, `${JSON.stringify({ + requestId: state.requestId, + threadId: state.threadId, + threadTitle: state.threadTitle, + machineName: state.machineName, + cwd: state.cwd, + ...row, + })}\n`, 'utf8') + } + await fs.writeFile(currentFile, renderActiveMirrorMarkdown(state), 'utf8') + if (!state.ideOpened) { + state.ideOpened = true + openActiveMirrorInIde(currentFile) + } + }).catch(() => {}) + return chain + } + + const record = (event = {}) => { + const kind = String(event.kind || event.type || 'event') + const text = eventText(event) + const row = { + at: new Date().toISOString(), + kind, + text: markdownText(text, 6000), + } + if (event.currentFile) row.currentFile = String(event.currentFile) + if (event.eventsFile) row.eventsFile = String(event.eventsFile) + if (kind === 'codex_reasoning') state.currentReasoning = markdownText(event.text || text, 6000) + if (kind === 'codex_command' || kind === 'start') state.currentCommand = markdownText(event.command || text, 3000) + if (kind === 'codex_files') state.currentFiles = markdownText(event.text || text, 3000) + if (kind === 'codex_tool') state.currentTool = markdownText(event.text || text, 3000) + if (kind === 'codex_todo') state.currentTodo = markdownText(event.text || text, 3000) + if (kind === 'codex_message') state.assistantText = markdownText(event.text || text, 12000) + if (kind === 'done' || kind === 'codex_turn_completed') state.status = 'completed' + if (kind === 'timeout') state.status = 'timeout' + if (kind === 'error' && !/reconnecting/i.test(text)) state.status = 'failed' + state.events.push(row) + state.events = state.events.slice(-ACTIVE_MIRROR_MAX_EVENTS) + write(row) + } + + return { + currentFile, + eventsFile, + record, + async complete(status, assistantText = '') { + state.status = status + state.finishedAt = new Date().toISOString() + if (assistantText) state.assistantText = markdownText(assistantText, 12000) + await write({ + at: state.finishedAt, + kind: `mirror_${status}`, + text: status, + }) + }, + } +} + +function textFromUnknown(value) { + if (typeof value === 'string') return value + if (Array.isArray(value)) return value.map(textFromUnknown).filter(Boolean).join('\n') + if (value && typeof value === 'object') { + return [ + value.text, + value.summary, + value.content, + value.message, + value.delta, + ].map(textFromUnknown).filter(Boolean).join('\n') + } + return '' +} + +function reasoningTextFromItem(item) { + return textFromUnknown([ + item?.summary, + item?.summary_text, + item?.text, + item?.content, + ]) +} + +function reasoningSummaryEventKey(event) { + const itemId = String(event?.item_id || event?.item?.id || event?.response_id || 'reasoning').trim() || 'reasoning' + const summaryIndex = String(event?.summary_index ?? event?.content_index ?? event?.output_index ?? 0) + return `${itemId}:${summaryIndex}` +} + +function reasoningSummaryTextFromEvent(event) { + return textFromUnknown([ + event?.text, + event?.delta, + event?.part, + event?.summary, + event?.summary_text, + ]) +} + +function shouldEmitReasoningSummaryDelta(record, pending) { + const text = String(pending || '').trim() + if (!text) return false + if (/[.!?。!?]\s*$/.test(text)) return true + if (text.length >= REASONING_SUMMARY_DELTA_MIN_CHARS) return true + return nowMs() - Number(record.lastEmittedAt || 0) >= REASONING_SUMMARY_DELTA_MAX_WAIT_MS && text.length >= 32 +} + +function updateReasoningSummaryState(state, event, mode) { + if (!state) return null + const key = reasoningSummaryEventKey(event) + const record = state.get(key) || { text: '', emittedLength: 0, lastEmittedAt: 0 } + if (mode === 'delta') { + const delta = textFromUnknown(event?.delta) + if (!delta) return null + record.text += delta + const pending = record.text.slice(record.emittedLength).trim() + if (!shouldEmitReasoningSummaryDelta(record, pending)) { + state.set(key, record) + return null + } + record.emittedLength = record.text.length + record.lastEmittedAt = nowMs() + state.set(key, record) + return pending + } + + const text = reasoningSummaryTextFromEvent(event) + if (text) record.text = text + const pending = record.text.slice(record.emittedLength).trim() || (!record.emittedLength ? record.text.trim() : '') + record.emittedLength = record.text.length + record.lastEmittedAt = nowMs() + state.set(key, record) + return pending +} + +function createCodexJsonEventMapper() { + const reasoningSummaryState = new Map() + return (event) => codexJsonEventToBridgeEvent(event, reasoningSummaryState) +} + +function summarizeFileChanges(changes) { + const list = Array.isArray(changes) ? changes : [] + if (!list.length) return 'File changes reported.' + return list + .slice(0, 12) + .map((change) => `${String(change.kind || 'update')}: ${String(change.path || '')}`.trim()) + .join('\n') +} + +function codexJsonEventToBridgeEvent(event, reasoningSummaryState = null) { + const type = String(event?.type || '') + const item = event?.item && typeof event.item === 'object' ? event.item : null + const itemType = String(item?.type || '') + if (type === 'response.reasoning_summary_text.delta') { + const text = truncateText(updateReasoningSummaryState(reasoningSummaryState, event, 'delta')) + return text ? { kind: 'codex_reasoning', message: text } : null + } + if (type === 'response.reasoning_summary_text.done') { + const text = truncateText(updateReasoningSummaryState(reasoningSummaryState, event, 'done')) + return text ? { kind: 'codex_reasoning', message: text } : null + } + if (type === 'response.reasoning_summary_part.done' || type === 'response.reasoning_summary_part.added') { + const text = truncateText(reasoningSummaryTextFromEvent(event)) + return text ? { kind: 'codex_reasoning', message: text } : null + } + if (type === 'response.reasoning_text.delta' || type === 'response.reasoning_text.done') return null + if (type === 'thread.started') { + const sessionId = String(event.thread_id || '').trim() + return { kind: 'codex_thread', sessionId, message: `Thread started: ${sessionId}`.trim() } + } + if (type === 'turn.started') return { kind: 'codex_turn', message: 'Turn started.' } + if (type === 'turn.completed') { + const usage = event.usage || {} + const tokens = Number(usage.input_tokens || 0) + Number(usage.output_tokens || 0) + return { kind: 'codex_turn_completed', message: tokens ? `Turn completed. Tokens: ${tokens}.` : 'Turn completed.' } + } + if (type === 'turn.failed') return { kind: 'error', message: event?.error?.message || 'Turn failed.' } + if (type === 'error') return { kind: 'error', message: event.message || 'Codex stream error.' } + if (!item) return null + + const prefix = type === 'item.started' ? 'Started' : type === 'item.updated' ? 'Updated' : 'Completed' + if (itemType === 'reasoning') { + const text = truncateText(reasoningTextFromItem(item)) + return text ? { kind: 'codex_reasoning', message: text } : null + } + if (itemType === 'agent_message') return { kind: 'codex_message', message: 'Assistant response received.', text: truncateText(item.text || '') } + if (itemType === 'command_execution') { + const status = item.status ? ` - ${item.status}` : '' + return { + kind: 'codex_command', + command: item.command || '', + message: `${prefix} command${status}: ${item.command || ''}`.trim(), + text: truncateText(item.aggregated_output || ''), + } + } + if (itemType === 'file_change') { + return { + kind: 'codex_files', + message: `${prefix} file change${item.status ? ` - ${item.status}` : ''}.`, + text: summarizeFileChanges(item.changes), + } + } + if (itemType === 'mcp_tool_call') { + return { + kind: 'codex_tool', + message: `${prefix} MCP ${item.server || ''}.${item.tool || ''}${item.status ? ` - ${item.status}` : ''}`.trim(), + text: truncateText(item.error?.message || ''), + } + } + if (itemType === 'todo_list') { + const todos = Array.isArray(item.items) + ? item.items.map((todo) => `${todo.completed ? '[x]' : '[ ]'} ${todo.text || ''}`).join('\n') + : '' + return { kind: 'codex_todo', message: `${prefix} todo list.`, text: truncateText(todos) } + } + if (itemType === 'web_search') return { kind: 'codex_tool', message: `${prefix} web search: ${item.query || ''}`.trim() } + if (itemType === 'error') return { kind: 'error', message: item.message || 'Codex item error.' } + return { kind: 'codex_event', message: `${prefix} ${itemType || type}.` } +} + +function finalMessageFromCodexEvent(event) { + const item = event?.item && typeof event.item === 'object' ? event.item : null + if (!item || item.type !== 'agent_message') return '' + return String(item.text || '').trim() +} + +async function runCodexForPayload(prompt, payload, onEvent = () => {}, cwd = CODEX_CWD, meta = {}) { + const baseArgs = payload?.resume ? buildResumeArgs(payload?.threadId) : CODEX_ARGS + const invocation = await codexInvocationForPayload(baseArgs, payload, cwd) + if (isNdcAgentCorePayload(payload)) { + try { + const context = buildNdcAgentMcpContext(payload, cwd) + onEvent({ + kind: 'ndc_agent_mcp_context', + message: `NDC Agent MCP API base: ${context.ndcAgentMcpApiBaseUrl}`, + }) + } catch {} + } + if (Array.isArray(invocation.dynamicMcpServerNames) && invocation.dynamicMcpServerNames.length) { + onEvent({ + kind: 'run_profile', + message: `Dynamic run profile MCP servers: ${invocation.dynamicMcpServerNames.join(', ')}`, + }) + } + const control = { + requestId: meta?.requestId, + threadId: payload?.threadId, + } + if (baseArgs === CODEX_ARGS) { + return runCodex(prompt, invocation.args, onEvent, MESSAGE_TIMEOUT_MS, cwd, control, invocation.env) + } + try { + return await runCodex(prompt, invocation.args, onEvent, RESUME_TIMEOUT_MS, cwd, control, invocation.env) + } catch (error) { + if (!isResumeRecoverableError(error)) throw error + onEvent({ kind: 'fallback', message: 'Codex resume failed; falling back to a new exec run.' }) + const fallback = await codexInvocationForPayload(CODEX_ARGS, payload, cwd) + return runCodex(prompt, fallback.args, onEvent, MESSAGE_TIMEOUT_MS, cwd, control, fallback.env) + } +} + +function runCodex(prompt, args = CODEX_ARGS, onEvent = () => {}, timeoutMs = MESSAGE_TIMEOUT_MS, cwd = CODEX_CWD, control = {}, envOverrides = {}) { + return new Promise((resolve, reject) => { + const startedAt = nowMs() + let firstOutputSeen = false + const jsonOutput = usesJsonOutput(args) + let jsonBuffer = '' + let finalJsonMessage = '' + let plainStdout = '' + const mapCodexJsonEvent = createCodexJsonEventMapper() + onEvent({ + kind: 'start', + command: `${CODEX_BIN} ${args.join(' ')}`, + cwd, + message: 'Codex process started.', + }) + const child = spawn(CODEX_BIN, args, { + cwd, + env: { ...process.env, ...(envOverrides || {}) }, + shell: process.platform === 'win32', + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }) + const activeRun = registerActiveCodexRun(control, child, onEvent) + let stdout = '' + let stderr = '' + let killed = false + const timeout = setTimeout(() => { + killed = true + onEvent({ kind: 'timeout', message: 'Codex process timed out.' }) + 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() diff --git a/services/ai-workspace-assistant/src/assets/ai-workspace-npm/bin/nodedc-ai-workspace-bridge.mjs b/services/ai-workspace-assistant/src/assets/ai-workspace-npm/bin/nodedc-ai-workspace-bridge.mjs new file mode 100755 index 0000000..95e5088 --- /dev/null +++ b/services/ai-workspace-assistant/src/assets/ai-workspace-npm/bin/nodedc-ai-workspace-bridge.mjs @@ -0,0 +1,878 @@ +#!/usr/bin/env node +import { copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import fssync from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawn, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_GATEWAY = "https://ai-hub.nodedc.ru"; +const PACKAGE_NAME = "@nodedc/ai-workspace-bridge"; +const SERVICE_NAME = "NDC AI Workspace Bridge"; +const WINDOWS_TASK_NAME = "NDC AI Workspace Bridge"; +const HEALTH_PATH = "/api/ai-workspace/bridge/v1/health"; +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 CLI_FILE = fileURLToPath(import.meta.url); +const PACKAGE_ROOT = path.resolve(path.dirname(CLI_FILE), ".."); +const ASSETS_DIR = path.join(PACKAGE_ROOT, "assets"); + +class UsageError extends Error {} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + printHelp(args.command); + return; + } + + if (args.command === "setup") return runSetup(args); + if (args.command === "status") return runStatus(args, { doctor: false }); + if (args.command === "doctor") return runStatus(args, { doctor: true }); + if (args.command === "start") return runStart(args); + if (args.command === "stop") return runStop(args); + if (args.command === "logs") return runLogs(args); + + throw new UsageError(`Unknown command: ${args.command}`); +} + +async function runSetup(args) { + if (!args.setupCode) { + throw new UsageError(`Missing setup code. Use: ai-workspace-bridge setup `); + } + ensureNodeRuntime(); + + const gateway = cleanHttpEndpoint(args.gateway || process.env.NODEDC_AI_WORKSPACE_GATEWAY || DEFAULT_GATEWAY); + if (!gateway) throw new UsageError("--gateway must be an http(s) URL."); + + console.log(`${SERVICE_NAME} setup`); + console.log(`Gateway: ${gateway}`); + const redeemPayload = await redeemSetupCode(gateway, args.setupCode); + const setup = normalizeSetupPayload(redeemPayload); + const bridge = setup.bridge; + + const installRoot = path.resolve(expandHome(args.installRoot || defaultInstallRoot())); + const workspace = path.resolve(expandHome(args.workspace || bridge.workspace || process.cwd())); + const port = sanitizePort(args.port || bridge.port || 8787); + const codexHome = path.join(installRoot, "codex-home"); + const paths = buildInstallPaths(installRoot); + const pathDirs = resolvePathDirs(); + + await mkdir(paths.logsDir, { recursive: true }); + await mkdir(workspace, { recursive: true }); + await copyFile(path.join(ASSETS_DIR, "worker.mjs"), paths.workerPath); + await copyFile(path.join(ASSETS_DIR, "ndcAgentMcpServer.mjs"), paths.ndcAgentMcpServerPath); + await writeStartScript(paths.startScriptPath); + await writeConfig(paths.configPath, { + service: SERVICE_NAME, + packageName: PACKAGE_NAME, + installedAt: new Date().toISOString(), + gateway, + installRoot, + workspace, + port, + codexHome, + worker: paths.workerPath, + ndcAgentMcpServer: paths.ndcAgentMcpServerPath, + startScript: paths.startScriptPath, + pidPath: paths.pidPath, + logPath: paths.logPath, + healthPath: HEALTH_PATH, + nodePath: process.execPath, + pathDirs, + hubUrl: bridge.hubUrl, + hubUrls: bridge.hubUrls, + pairingCode: bridge.pairingCode, + machineName: bridge.machineName || os.hostname(), + appMcpServers: bridge.appMcpServers, + }); + + if (!args.noCodexInstall) { + await ensureCodexCli({ pathDirs }); + } + await setupCodexHome(codexHome, bridge.appMcpServers); + if (!args.noCodexLogin) { + await ensureCodexLogin(codexHome, { pathDirs }); + } + + if (!args.noAutostart) { + await installAutostart(paths.startScriptPath, installRoot).catch((error) => { + console.warn(`Autostart was not registered: ${error.message}`); + }); + } + + await stopBridge(installRoot).catch(() => {}); + await startBridge(installRoot); + const health = await waitForHealth(port, 15000); + + console.log(`${SERVICE_NAME} setup complete.`); + console.log(`Install root: ${installRoot}`); + console.log(`Workspace: ${workspace}`); + console.log(`Health: ${health.ok ? "online" : "not ready"}`); + console.log(`Run: ai-workspace-bridge doctor`); +} + +async function runStatus(args, { doctor }) { + const installRoot = path.resolve(expandHome(args.installRoot || defaultInstallRoot())); + const config = await readLocalConfig(installRoot); + if (!config) { + console.log(`${SERVICE_NAME} is not installed at ${installRoot}`); + process.exitCode = 1; + return; + } + + const health = await fetchHealth(config.port); + const codex = doctor ? inspectCodex(config.codexHome, config.pathDirs || []) : null; + console.log(`${SERVICE_NAME} status`); + console.log(`Install root: ${config.installRoot || installRoot}`); + console.log(`Workspace: ${config.workspace || ""}`); + console.log(`Port: ${config.port || ""}`); + console.log(`Hub: ${health.ok ? "online" : "offline"}`); + if (doctor) { + console.log(`Node: ${process.version}`); + console.log(`Codex CLI: ${codex.codexOk ? codex.codexVersion || "ok" : "missing"}`); + console.log(`Codex auth: ${codex.authOk ? "present" : "missing"}`); + if (codex.error) console.log(`Codex detail: ${codex.error}`); + } + process.exitCode = health.ok && (!doctor || (codex.codexOk && codex.authOk)) ? 0 : 1; +} + +async function runStart(args) { + const installRoot = path.resolve(expandHome(args.installRoot || defaultInstallRoot())); + await startBridge(installRoot); + console.log(`${SERVICE_NAME} started.`); +} + +async function runStop(args) { + const installRoot = path.resolve(expandHome(args.installRoot || defaultInstallRoot())); + await stopBridge(installRoot); + console.log(`${SERVICE_NAME} stopped.`); +} + +async function runLogs(args) { + const installRoot = path.resolve(expandHome(args.installRoot || defaultInstallRoot())); + const config = await readLocalConfig(installRoot); + const logPath = config?.logPath || path.join(installRoot, "logs", "bridge.log"); + const text = await readFile(logPath, "utf8").catch(() => ""); + console.log(tailLines(text, Number(args.lines || 120))); +} + +function parseArgs(rawArgs) { + let args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs; + let command = "setup"; + if (["setup", "status", "doctor", "start", "stop", "logs"].includes(args[0])) { + command = args[0]; + args = args.slice(1); + } else if (args[0] === "help") { + return { command: "help", help: true }; + } + + const parsed = { + command, + gateway: "", + setupCode: "", + installRoot: "", + workspace: "", + port: "", + lines: "", + help: false, + noAutostart: false, + noCodexInstall: false, + noCodexLogin: false, + }; + const positional = []; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === "-h" || arg === "--help") { + parsed.help = true; + continue; + } + if (arg === "--gateway") { + parsed.gateway = requireValue(args, ++i, arg); + continue; + } + if (arg.startsWith("--gateway=")) { + parsed.gateway = arg.slice("--gateway=".length); + continue; + } + if (arg === "--install-root") { + parsed.installRoot = requireValue(args, ++i, arg); + continue; + } + if (arg.startsWith("--install-root=")) { + parsed.installRoot = arg.slice("--install-root=".length); + continue; + } + if (arg === "--workspace") { + parsed.workspace = requireValue(args, ++i, arg); + continue; + } + if (arg.startsWith("--workspace=")) { + parsed.workspace = arg.slice("--workspace=".length); + continue; + } + if (arg === "--port") { + parsed.port = requireValue(args, ++i, arg); + continue; + } + if (arg.startsWith("--port=")) { + parsed.port = arg.slice("--port=".length); + continue; + } + if (arg === "--lines") { + parsed.lines = requireValue(args, ++i, arg); + continue; + } + if (arg.startsWith("--lines=")) { + parsed.lines = arg.slice("--lines=".length); + continue; + } + if (arg === "--no-autostart") { + parsed.noAutostart = true; + continue; + } + if (arg === "--no-codex-install") { + parsed.noCodexInstall = true; + continue; + } + if (arg === "--no-codex-login") { + parsed.noCodexLogin = true; + continue; + } + if (arg === "--setup-code" || arg === "--code") { + parsed.setupCode = requireValue(args, ++i, arg); + continue; + } + if (arg.startsWith("--setup-code=")) { + parsed.setupCode = arg.slice("--setup-code=".length); + continue; + } + if (arg.startsWith("--code=")) { + parsed.setupCode = arg.slice("--code=".length); + continue; + } + if (arg.startsWith("-")) throw new UsageError(`Unknown argument: ${arg}`); + positional.push(arg); + } + + if (command === "setup") { + parsed.setupCode ||= positional.shift() || ""; + } + if (positional.length) throw new UsageError(`Unexpected argument: ${positional[0]}`); + return parsed; +} + +function requireValue(args, index, flag) { + const value = args[index]; + if (!value || value.startsWith("--")) throw new UsageError(`Missing value for ${flag}.`); + return value; +} + +function printHelp() { + console.log(`Install ${SERVICE_NAME}. + +Usage: + ai-workspace-bridge setup [--gateway ] [--install-root ] [--workspace ] [--port ] + ai-workspace-bridge status [--install-root ] + ai-workspace-bridge doctor [--install-root ] + ai-workspace-bridge start [--install-root ] + ai-workspace-bridge stop [--install-root ] + ai-workspace-bridge logs [--install-root ] + +Registry form: + npx --yes ${PACKAGE_NAME} setup --gateway ${DEFAULT_GATEWAY} +`); +} + +async function redeemSetupCode(gateway, setupCode) { + let response; + try { + response = await fetch(`${gateway}/api/ai-workspace/setup-codes/redeem`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ setupCode, setup_code: setupCode }), + }); + } catch (error) { + throw new Error(`Setup code redeem failed: ${error.message}`); + } + + const bodyText = await response.text(); + let data = {}; + try { + data = bodyText ? JSON.parse(bodyText) : {}; + } catch { + throw new Error(`Setup code redeem failed: HTTP ${response.status} ${bodyText}`); + } + if (!response.ok || data.ok === false) { + throw new Error(`Setup code redeem failed: ${data.error || `HTTP ${response.status}`}`); + } + return data; +} + +function normalizeSetupPayload(payload) { + const setup = isPlainObject(payload?.setup) ? payload.setup : {}; + const bridge = isPlainObject(setup.bridge) ? setup.bridge : {}; + const pairingCode = cleanString(bridge.pairingCode || bridge.pairing_code, 80); + const hubUrl = cleanWsEndpoint(bridge.hubUrl || bridge.hub_url); + const hubUrls = uniqueStrings([ + hubUrl, + ...(Array.isArray(bridge.hubUrls) ? bridge.hubUrls : []), + ...(Array.isArray(bridge.hub_urls) ? bridge.hub_urls : []), + ].map(cleanWsEndpoint)); + if (!pairingCode) throw new Error("Setup payload is missing pairing code."); + if (!hubUrls.length) throw new Error("Setup payload is missing Hub URL."); + return { + bridge: { + hubUrl: hubUrls[0], + hubUrls, + pairingCode, + machineName: cleanString(bridge.machineName || bridge.machine_name, 120) || os.hostname(), + workspace: cleanString(bridge.workspace || bridge.workspacePath || bridge.workspace_path, 1000), + port: sanitizePort(bridge.port || 8787), + appMcpServers: sanitizeMcpServers(bridge.appMcpServers || bridge.app_mcp_servers), + }, + }; +} + +async function writeStartScript(startScriptPath) { + const source = `#!/usr/bin/env node +import { mkdir, open, readFile, writeFile } from "node:fs/promises"; +import fssync from "node:fs"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const configPath = path.join(root, "config.json"); +const config = JSON.parse(await readFile(configPath, "utf8")); +await mkdir(path.dirname(config.logPath), { recursive: true }); +await writeFile(config.pidPath, String(process.pid), "utf8").catch(() => {}); + +let stopping = false; +let child = null; +process.on("SIGTERM", () => stop("SIGTERM")); +process.on("SIGINT", () => stop("SIGINT")); + +function stop(signal) { + stopping = true; + if (child && !child.killed) { + try { child.kill(signal); } catch {} + } + setTimeout(() => process.exit(0), 1500).unref(); +} + +function buildEnv() { + const delimiter = process.platform === "win32" ? ";" : ":"; + const pathDirs = Array.isArray(config.pathDirs) ? config.pathDirs.filter(Boolean) : []; + return { + ...process.env, + CODEX_HOME: config.codexHome, + AI_BRIDGE_HOST: "0.0.0.0", + AI_BRIDGE_PORT: String(config.port || 8787), + AI_BRIDGE_CODEX_CWD: config.workspace, + AI_BRIDGE_CODEX_BIN: "codex", + AI_BRIDGE_CODEX_ARGS: ${JSON.stringify(JSON.stringify(DEFAULT_CODEX_ARGS))}, + AI_BRIDGE_ALLOW_ORIGIN: "*", + AI_BRIDGE_HUB_URL: config.hubUrl || "", + AI_BRIDGE_HUB_URLS: Array.isArray(config.hubUrls) ? config.hubUrls.join(",") : "", + AI_BRIDGE_PAIRING_CODE: config.pairingCode || "", + AI_BRIDGE_MACHINE_NAME: config.machineName || "", + AI_BRIDGE_ACTIVE_MIRROR_DIR: path.join(config.workspace || root, ".nodedc", "ai-workspace"), + AI_BRIDGE_ACTIVE_MIRROR_OPEN_IDE: "1", + PATH: [...pathDirs, process.env.PATH || ""].filter(Boolean).join(delimiter), + Path: [...pathDirs, process.env.Path || process.env.PATH || ""].filter(Boolean).join(delimiter), + }; +} + +async function runOnce() { + const log = await open(config.logPath, "a"); + try { + child = spawn(config.nodePath || process.execPath, [config.worker], { + cwd: config.installRoot || root, + env: buildEnv(), + stdio: ["ignore", log.fd, log.fd], + windowsHide: true, + }); + const code = await new Promise((resolve) => { + child.on("exit", (exitCode, signal) => resolve(signal || exitCode || 0)); + child.on("error", (error) => resolve(error.message)); + }); + child = null; + return code; + } finally { + await log.close().catch(() => {}); + } +} + +while (!stopping) { + const code = await runOnce(); + if (stopping) break; + await new Promise((resolve) => setTimeout(resolve, 5000)); +} +`; + await writeFile(startScriptPath, source, "utf8"); + await chmodIfPossible(startScriptPath, 0o755); +} + +async function writeConfig(configPath, config) { + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +} + +async function setupCodexHome(codexHome, appMcpServers) { + await mkdir(codexHome, { recursive: true }); + await copyIfExists(path.join(os.homedir(), ".codex", "auth.json"), path.join(codexHome, "auth.json")); + await mergeCodexMcpConfig(path.join(codexHome, "config.toml"), appMcpServers); +} + +async function mergeCodexMcpConfig(configPath, appMcpServers) { + const servers = sanitizeMcpServers(appMcpServers); + if (!servers.length) { + await touchFile(configPath); + return; + } + let raw = await readFile(configPath, "utf8").catch(() => ""); + for (const server of servers) { + raw = removeTomlSection(raw, `[mcp_servers.${server.serverName}]`); + raw = removeTomlSection(raw, `[mcp_servers.${server.serverName}.headers]`); + raw = removeTomlSection(raw, `[mcp_servers.${server.serverName}.http_headers]`); + const lines = [ + `[mcp_servers.${server.serverName}]`, + `url = ${tomlString(server.url)}`, + `startup_timeout_sec = ${server.startupTimeoutSec}`, + `tool_timeout_sec = ${server.toolTimeoutSec}`, + `required = ${server.required ? "true" : "false"}`, + ]; + if (Object.keys(server.httpHeaders).length) { + lines.push("", `[mcp_servers.${server.serverName}.http_headers]`); + for (const [key, value] of Object.entries(server.httpHeaders)) { + lines.push(`${tomlBareKey(key)} = ${tomlString(value)}`); + } + } + raw = `${raw.trim()}\n\n${lines.join("\n")}\n`; + } + await mkdir(path.dirname(configPath), { recursive: true }); + await writeFile(configPath, raw.trimStart(), "utf8"); +} + +function removeTomlSection(raw, header) { + const lines = String(raw || "").split(/\r?\n/); + const out = []; + let skipping = false; + const normalizedHeader = normalizeTomlHeader(header); + for (const line of lines) { + const current = normalizeTomlHeader(line); + if (current) { + skipping = current === normalizedHeader; + if (skipping) continue; + } + if (!skipping) out.push(line); + } + return out.join("\n").trimEnd(); +} + +function normalizeTomlHeader(line) { + const text = String(line || "").trim(); + if (!text.startsWith("[") || !text.endsWith("]")) return ""; + return text.replace(/\s+/g, "").replace(/["']/g, ""); +} + +async function ensureCodexCli({ pathDirs }) { + const before = runCommandQuiet("codex", ["--version"], { pathDirs }); + if (before.ok) return; + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + console.log("Installing Codex CLI with npm..."); + await runCommand(npm, ["install", "-g", "@openai/codex", "--no-audit", "--no-fund"], { pathDirs }); + const after = runCommandQuiet("codex", ["--version"], { pathDirs: resolvePathDirs() }); + if (!after.ok) throw new Error("Codex CLI was installed, but codex is not available in PATH."); +} + +async function ensureCodexLogin(codexHome, { pathDirs }) { + if (fssync.existsSync(path.join(codexHome, "auth.json"))) return; + console.log("Codex auth is missing. Opening codex login..."); + await runCommand("codex", ["login"], { + pathDirs, + env: { CODEX_HOME: codexHome }, + stdio: "inherit", + }).catch((error) => { + console.warn(`Codex login did not complete: ${error.message}`); + }); +} + +function inspectCodex(codexHome, pathDirs) { + const version = runCommandQuiet("codex", ["--version"], { pathDirs }); + return { + codexOk: version.ok, + codexVersion: version.stdout.trim(), + authOk: fssync.existsSync(path.join(codexHome || "", "auth.json")), + error: version.ok ? "" : version.error, + }; +} + +async function installAutostart(startScriptPath, installRoot) { + if (process.platform === "win32") return installWindowsTask(startScriptPath); + if (process.platform === "darwin") return installLaunchAgent(startScriptPath); + return installSystemdUserService(startScriptPath, installRoot); +} + +async function installWindowsTask(startScriptPath) { + const taskRun = `"${process.execPath}" "${startScriptPath}"`; + runCommandQuiet("schtasks", ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"], {}); + await runCommand("schtasks", [ + "/Create", + "/TN", + WINDOWS_TASK_NAME, + "/SC", + "ONLOGON", + "/TR", + taskRun, + "/RL", + "HIGHEST", + "/F", + ], {}); +} + +async function installLaunchAgent(startScriptPath) { + const label = "ru.nodedc.ai-workspace-bridge"; + const plistDir = path.join(os.homedir(), "Library", "LaunchAgents"); + const plistPath = path.join(plistDir, `${label}.plist`); + await mkdir(plistDir, { recursive: true }); + await writeFile(plistPath, ` + + + Label${label} + ProgramArguments${xmlEscape(process.execPath)}${xmlEscape(startScriptPath)} + RunAtLoad + KeepAlive + StandardOutPath${xmlEscape(path.join(path.dirname(startScriptPath), "logs", "launchd.out.log"))} + StandardErrorPath${xmlEscape(path.join(path.dirname(startScriptPath), "logs", "launchd.err.log"))} + +`, "utf8"); + runCommandQuiet("launchctl", ["bootout", `gui/${process.getuid?.()}`, plistPath], {}); + await runCommand("launchctl", ["bootstrap", `gui/${process.getuid?.()}`, plistPath], {}); + await runCommand("launchctl", ["enable", `gui/${process.getuid?.()}/${label}`], {}); +} + +async function installSystemdUserService(startScriptPath, installRoot) { + const serviceDir = path.join(os.homedir(), ".config", "systemd", "user"); + const servicePath = path.join(serviceDir, "nodedc-ai-workspace-bridge.service"); + await mkdir(serviceDir, { recursive: true }); + await writeFile(servicePath, `[Unit] +Description=NDC AI Workspace Bridge + +[Service] +Type=simple +ExecStart=${process.execPath} ${startScriptPath} +Restart=always +RestartSec=5 +WorkingDirectory=${installRoot} + +[Install] +WantedBy=default.target +`, "utf8"); + await runCommand("systemctl", ["--user", "daemon-reload"], {}); + await runCommand("systemctl", ["--user", "enable", "--now", "nodedc-ai-workspace-bridge.service"], {}); +} + +async function startBridge(installRoot) { + const config = await readLocalConfig(installRoot); + if (!config?.startScript) throw new Error(`${SERVICE_NAME} is not installed at ${installRoot}`); + const child = spawn(config.nodePath || process.execPath, [config.startScript], { + cwd: installRoot, + detached: true, + stdio: "ignore", + windowsHide: true, + }); + child.unref(); +} + +async function stopBridge(installRoot) { + const config = await readLocalConfig(installRoot); + const pidPath = config?.pidPath || path.join(installRoot, "bridge.pid"); + const pidText = await readFile(pidPath, "utf8").catch(() => ""); + const pid = Number(pidText.trim()); + if (!Number.isInteger(pid) || pid <= 0) return; + if (process.platform === "win32") { + runCommandQuiet("taskkill", ["/PID", String(pid), "/T", "/F"], {}); + } else { + try { + process.kill(pid, "SIGTERM"); + } catch {} + } + await rm(pidPath, { force: true }).catch(() => {}); +} + +async function waitForHealth(port, timeoutMs) { + const started = Date.now(); + let latest = { ok: false, error: "not_checked" }; + while (Date.now() - started < timeoutMs) { + latest = await fetchHealth(port); + if (latest.ok) return latest; + await sleep(700); + } + return latest; +} + +async function fetchHealth(port) { + try { + const response = await fetch(`http://127.0.0.1:${sanitizePort(port)}${HEALTH_PATH}`); + const data = await response.json().catch(() => ({})); + return { ok: response.ok && data?.ok !== false, data }; + } catch (error) { + return { ok: false, error: String(error?.message || error) }; + } +} + +async function readLocalConfig(installRoot) { + try { + return JSON.parse(await readFile(path.join(installRoot, "config.json"), "utf8")); + } catch { + return null; + } +} + +function buildInstallPaths(installRoot) { + return { + logsDir: path.join(installRoot, "logs"), + logPath: path.join(installRoot, "logs", "bridge.log"), + pidPath: path.join(installRoot, "bridge.pid"), + configPath: path.join(installRoot, "config.json"), + workerPath: path.join(installRoot, "worker.mjs"), + ndcAgentMcpServerPath: path.join(installRoot, "ndcAgentMcpServer.mjs"), + startScriptPath: path.join(installRoot, "start-bridge.mjs"), + }; +} + +function defaultInstallRoot() { + if (process.platform === "win32") { + return path.join(process.env.LOCALAPPDATA || os.homedir(), "NDC", "AIWorkspaceBridge"); + } + if (process.platform === "darwin") { + return path.join(os.homedir(), "Library", "Application Support", "NDC", "AIWorkspaceBridge"); + } + return path.join(os.homedir(), ".local", "share", "nodedc", "ai-workspace-bridge"); +} + +function resolvePathDirs() { + const dirs = [path.dirname(process.execPath)]; + const prefix = runCommandQuiet(process.platform === "win32" ? "npm.cmd" : "npm", ["prefix", "-g"], {}).stdout.trim(); + if (prefix) { + dirs.push(process.platform === "win32" ? prefix : path.join(prefix, "bin")); + } + return uniqueStrings(dirs); +} + +function runCommandQuiet(command, args, options = {}) { + try { + const result = spawnSync(command, args, { + env: commandEnv(options), + encoding: "utf8", + shell: process.platform === "win32", + windowsHide: true, + }); + return { + ok: result.status === 0, + stdout: result.stdout || "", + stderr: result.stderr || "", + error: result.error ? result.error.message : result.stderr || "", + }; + } catch (error) { + return { ok: false, stdout: "", stderr: "", error: String(error?.message || error) }; + } +} + +function runCommand(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + env: commandEnv(options), + stdio: options.stdio || "inherit", + shell: process.platform === "win32", + windowsHide: true, + }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) resolve(); + else reject(new Error(`${command} exited with code ${code}`)); + }); + }); +} + +function commandEnv(options = {}) { + const delimiter = process.platform === "win32" ? ";" : ":"; + const pathDirs = Array.isArray(options.pathDirs) ? options.pathDirs.filter(Boolean) : []; + const basePath = process.env.PATH || process.env.Path || ""; + return { + ...process.env, + ...(options.env || {}), + PATH: [...pathDirs, basePath].filter(Boolean).join(delimiter), + Path: [...pathDirs, process.env.Path || basePath].filter(Boolean).join(delimiter), + }; +} + +function ensureNodeRuntime() { + const major = Number(process.versions.node.split(".")[0]); + if (!Number.isInteger(major) || major < 22) { + throw new Error(`${SERVICE_NAME} requires Node.js 22+ for WebSocket bridge mode. Current: ${process.version}`); + } +} + +function sanitizeMcpServers(value) { + const items = Array.isArray(value) ? value : []; + return items.map((item) => { + if (!isPlainObject(item)) return null; + const serverName = safeMcpServerName(item.serverName || item.server_name || item.name); + const url = cleanHttpEndpoint(item.url); + if (!serverName || !url) return null; + return { + serverName, + url, + required: item.required === true, + startupTimeoutSec: sanitizeInteger(item.startupTimeoutSec || item.startup_timeout_sec, 20, 1, 300), + toolTimeoutSec: sanitizeInteger(item.toolTimeoutSec || item.tool_timeout_sec, 60, 1, 3600), + httpHeaders: sanitizeHeaders(item.httpHeaders || item.http_headers || item.headers), + }; + }).filter(Boolean); +} + +function sanitizeHeaders(value) { + if (!isPlainObject(value)) return {}; + const headers = {}; + for (const [key, raw] of Object.entries(value)) { + const name = cleanString(key, 120); + const text = cleanString(raw, 2000); + if (!name || !text || /[\r\n\0]/.test(name) || /[\r\n\0]/.test(text)) continue; + headers[name] = text; + } + return headers; +} + +function safeMcpServerName(value) { + const text = String(value || "").trim().replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80); + return text || ""; +} + +function cleanHttpEndpoint(value) { + const text = cleanString(value, 1000); + 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 cleanWsEndpoint(value) { + const text = cleanString(value, 1000); + if (!text) return ""; + try { + const url = new URL(text); + if (url.protocol !== "ws:" && url.protocol !== "wss:") return ""; + url.username = ""; + url.password = ""; + return url.toString().replace(/\/+$/, ""); + } catch { + return ""; + } +} + +function sanitizePort(value) { + const port = Number(value || 8787); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new UsageError("port_invalid"); + return port; +} + +function sanitizeInteger(value, fallback, min, max) { + const number = Number(value || fallback); + if (!Number.isFinite(number)) return fallback; + return Math.min(Math.max(Math.trunc(number), min), max); +} + +function cleanString(value, max) { + return String(value || "").trim().slice(0, max); +} + +function uniqueStrings(values) { + return Array.from(new Set((values || []).map((item) => String(item || "").trim()).filter(Boolean))); +} + +function expandHome(value) { + const text = String(value || ""); + if (text === "~") return os.homedir(); + if (text.startsWith("~/") || text.startsWith("~\\")) return path.join(os.homedir(), text.slice(2)); + return text; +} + +function tomlString(value) { + return JSON.stringify(String(value || "")); +} + +function tomlBareKey(value) { + const key = String(value || ""); + return /^[A-Za-z0-9_-]+$/.test(key) ? key : JSON.stringify(key); +} + +function xmlEscape(value) { + return String(value || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +async function copyIfExists(from, to) { + try { + await stat(from); + await mkdir(path.dirname(to), { recursive: true }); + await copyFile(from, to); + } catch {} +} + +async function touchFile(filePath) { + await mkdir(path.dirname(filePath), { recursive: true }); + if (!fssync.existsSync(filePath)) await writeFile(filePath, "", "utf8"); +} + +async function chmodIfPossible(filePath, mode) { + try { + await fssync.promises.chmod(filePath, mode); + } catch {} +} + +function tailLines(text, count) { + return String(text || "").split(/\r?\n/).slice(-Math.max(1, count || 120)).join("\n"); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isPlainObject(value) { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +main().catch((error) => { + console.error(error instanceof UsageError ? error.message : `Error: ${error.message || error}`); + process.exitCode = 1; +}); diff --git a/services/ai-workspace-assistant/src/assets/ai-workspace-npm/package.json b/services/ai-workspace-assistant/src/assets/ai-workspace-npm/package.json new file mode 100644 index 0000000..5fe0721 --- /dev/null +++ b/services/ai-workspace-assistant/src/assets/ai-workspace-npm/package.json @@ -0,0 +1,29 @@ +{ + "name": "@nodedc/ai-workspace-bridge", + "version": "0.1.0", + "description": "Install NODE.DC AI Workspace Bridge worker.", + "type": "module", + "bin": { + "ai-workspace-bridge": "./bin/nodedc-ai-workspace-bridge.mjs", + "nodedc-ai-workspace-bridge": "./bin/nodedc-ai-workspace-bridge.mjs" + }, + "engines": { + "node": ">=22" + }, + "files": [ + "assets/", + "bin/", + "README.md" + ], + "keywords": [ + "nodedc", + "ai-workspace", + "codex", + "bridge" + ], + "license": "UNLICENSED", + "private": false, + "publishConfig": { + "access": "public" + } +} diff --git a/services/ai-workspace-assistant/src/server.mjs b/services/ai-workspace-assistant/src/server.mjs index 7fec28e..b61be6c 100644 --- a/services/ai-workspace-assistant/src/server.mjs +++ b/services/ai-workspace-assistant/src/server.mjs @@ -1,5 +1,5 @@ import express from "express"; -import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import { readFile } from "node:fs/promises"; import { createServer } from "node:http"; import { Pool } from "pg"; @@ -14,6 +14,8 @@ const SUPPORTED_THREAD_STATES = new Set(["active", "archived"]); const SUPPORTED_MESSAGE_ROLES = new Set(["user", "assistant", "system", "tool"]); const SUPPORTED_TOOL_PACKS = new Set(["engine", "ops", "ndc-agent-core", "deploy", "docs"]); const SUPPORTED_RUN_STATUSES = new Set(["running", "completed", "failed", "timeout"]); +const AI_WORKSPACE_BRIDGE_PACKAGE_NAME = "@nodedc/ai-workspace-bridge"; +const AI_WORKSPACE_SETUP_CODE_PREFIX = "ndcaws"; const ASSISTANT_ACTION_TOOL_PROFILE_BASE = { schemaVersion: "ai-workspace.assistant-actions.v1", endpoint: "/api/ai-workspace/assistant/v1/actions", @@ -321,6 +323,59 @@ app.get("/api/ai-workspace/assistant/v1/executors/:executorId/agent/windows.ps1" res.send(source); })); +app.post("/api/ai-workspace/assistant/v1/executors/:executorId/agent/setup-command", requireInternalApi, asyncRoute(async (req, res) => { + const owner = getRequestOwner(req); + const executorId = sanitizeUuid(req.params.executorId, "executorId"); + const executor = await getExecutor(owner, executorId); + if (!executor) { + res.status(404).json({ ok: false, error: "ai_workspace_executor_not_found" }); + return; + } + if (!cleanPairingCode(executor.pairingCode || "")) { + res.status(400).json({ ok: false, error: "pairing_code_required" }); + return; + } + + const installerPort = installerPortFromQuery(req.body?.port || req.query?.port); + if (!installerPort) { + res.status(400).json({ ok: false, error: "installer_port_invalid" }); + return; + } + + const ownerSettings = await getOwnerSettings(owner); + const appMcpServers = installerMcpServersFromSettings(ownerSettings, isPlainObject(req.body) ? req.body : {}); + const setupCode = await createAiWorkspaceSetupCode(owner, executor, { + port: installerPort, + appMcpServers, + }); + const command = buildBridgeSetupCommand(setupCode.code); + res.status(201).json({ + ok: true, + owner: publicOwner(owner), + executorId: executor.id, + install: { + command, + packageName: config.bridgePackageName, + gatewayUrl: config.setupGatewayUrl, + expiresAt: setupCode.expiresAt, + }, + setupCode: { + suffix: setupCode.suffix, + expiresAt: setupCode.expiresAt, + }, + }); +})); + +app.post("/api/ai-workspace/assistant/v1/setup-codes/redeem", requireInternalApi, asyncRoute(async (req, res) => { + const setupCode = optionalString(req.body?.setupCode || req.body?.setup_code || req.query?.setupCode || req.query?.setup_code); + if (!setupCode) { + res.status(400).json({ ok: false, error: "setup_code_required" }); + return; + } + const setup = await redeemAiWorkspaceSetupCode(setupCode); + res.json({ ok: true, setup }); +})); + app.get("/api/ai-workspace/assistant/v1/threads", requireInternalApi, asyncRoute(async (req, res) => { const owner = getRequestOwner(req); const surface = req.query.surface ? sanitizeSurface(req.query.surface) : null; @@ -709,6 +764,160 @@ async function getExecutor(owner, executorId) { return result.rows[0] ? toExecutor(result.rows[0]) : null; } +async function createAiWorkspaceSetupCode(owner, executor, options = {}) { + const code = makeAiWorkspaceSetupCode(); + const expiresAt = new Date(Date.now() + config.setupCodeTtlSeconds * 1000); + const metadata = { + packageName: config.bridgePackageName, + setupGatewayUrl: config.setupGatewayUrl, + bridge: bridgeSetupPayload(executor, options), + }; + const client = await pool.connect(); + try { + await client.query("begin"); + await client.query( + `update ai_workspace_setup_codes + set status = 'revoked' + where owner_key = $1 and executor_id = $2 and status = 'active'`, + [owner.key, executor.id] + ); + await client.query( + `insert into ai_workspace_setup_codes ( + id, + owner_key, + owner_user_id, + owner_email, + executor_id, + code_hash, + code_suffix, + expires_at, + metadata + ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)`, + [ + randomUUID(), + owner.key, + owner.userId, + owner.email, + executor.id, + hashSetupCode(code), + setupCodeSuffix(code), + expiresAt, + JSON.stringify(metadata), + ] + ); + await client.query("commit"); + } catch (error) { + await client.query("rollback"); + throw error; + } finally { + client.release(); + } + return { + code, + suffix: setupCodeSuffix(code), + expiresAt: expiresAt.toISOString(), + }; +} + +async function redeemAiWorkspaceSetupCode(code) { + const client = await pool.connect(); + let committed = false; + try { + await client.query("begin"); + const result = await client.query( + `select c.*, e.name as executor_name + from ai_workspace_setup_codes c + left join ai_workspace_executors e on e.owner_key = c.owner_key and e.id = c.executor_id + where c.code_hash = $1 + for update of c`, + [hashSetupCode(code)] + ); + const row = result.rows[0]; + if (!row) throw badRequest("setup_code_invalid"); + if (row.status !== "active") throw httpError("setup_code_not_active", 410); + if (new Date(row.expires_at).getTime() <= Date.now()) { + await client.query( + "update ai_workspace_setup_codes set status = 'expired' where id = $1", + [row.id] + ); + await client.query("commit"); + committed = true; + throw httpError("setup_code_expired", 410); + } + + await client.query( + "update ai_workspace_setup_codes set status = 'used', used_at = now() where id = $1", + [row.id] + ); + await client.query("commit"); + committed = true; + + const metadata = isPlainObject(row.metadata) ? row.metadata : {}; + const bridge = isPlainObject(metadata.bridge) ? metadata.bridge : null; + if (!bridge?.pairingCode || !Array.isArray(bridge?.hubUrls) || !bridge.hubUrls.length) { + throw httpError("setup_payload_invalid", 500); + } + return { + service: "NDC AI Workspace Bridge", + packageName: optionalString(metadata.packageName) || config.bridgePackageName, + executor: { + id: row.executor_id, + name: optionalString(row.executor_name) || optionalString(bridge.machineName) || "Codex worker", + }, + expiresAt: toIso(row.expires_at), + bridge, + }; + } catch (error) { + if (!committed) { + try { + await client.query("rollback"); + } catch {} + } + throw error; + } finally { + client.release(); + } +} + +function bridgeSetupPayload(executor, options = {}) { + const hubUrls = uniqueStrings([config.hubWebSocketUrl, ...(config.hubFallbackWebSocketUrls || [])]); + return { + protocol: "ai-workspace-bridge/v1", + hubUrl: hubUrls[0] || "", + hubUrls, + pairingCode: cleanPairingCode(executor.pairingCode || ""), + machineName: optionalString(executor.name) || "Codex worker", + workspace: optionalString(executor.workspacePath) || "", + port: sanitizeInteger(options.port, 8787, 1, 65535), + appMcpServers: Array.isArray(options.appMcpServers) ? options.appMcpServers : [], + }; +} + +function buildBridgeSetupCommand(code) { + const parts = [ + "npx", + "--yes", + config.bridgePackageName, + "setup", + code, + "--gateway", + config.setupGatewayUrl, + ]; + return parts.join(" "); +} + +function makeAiWorkspaceSetupCode() { + return `${AI_WORKSPACE_SETUP_CODE_PREFIX}_${randomBytes(18).toString("base64url")}`; +} + +function hashSetupCode(code) { + return createHash("sha256").update(String(code || ""), "utf8").digest("hex"); +} + +function setupCodeSuffix(code) { + return String(code || "").replace(/[^A-Za-z0-9]/g, "").slice(-6).toUpperCase(); +} + async function selectExecutor(owner, executorId) { const executor = await getExecutor(owner, executorId); if (!executor) return null; @@ -2769,6 +2978,27 @@ async function migrate() { ) `); + await pool.query(` + create table if not exists ai_workspace_setup_codes ( + id uuid primary key, + owner_key text not null, + owner_user_id text, + owner_email text, + executor_id uuid not null references ai_workspace_executors(id) on delete cascade, + code_hash text not null unique, + code_suffix text not null, + status text not null default 'active', + expires_at timestamptz not null, + used_at timestamptz, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + constraint ai_workspace_setup_codes_owner_check + check (owner_user_id is not null or owner_email is not null), + constraint ai_workspace_setup_codes_status_check + check (status in ('active', 'used', 'expired', 'revoked')) + ) + `); + await pool.query("create index if not exists ai_workspace_executors_owner_idx on ai_workspace_executors(owner_key, updated_at desc)"); await pool.query("create index if not exists ai_workspace_executors_pairing_code_idx on ai_workspace_executors(pairing_code) where pairing_code is not null"); await pool.query("create index if not exists ai_workspace_threads_owner_surface_idx on ai_workspace_threads(owner_key, origin_surface, updated_at desc)"); @@ -2777,6 +3007,8 @@ async function migrate() { await pool.query("create index if not exists ai_workspace_runs_request_idx on ai_workspace_runs(owner_key, request_id)"); await pool.query("create index if not exists ai_workspace_run_events_run_idx on ai_workspace_run_events(run_id, occurred_at asc, created_at asc)"); await pool.query("create unique index if not exists ai_workspace_run_events_hub_event_idx on ai_workspace_run_events(run_id, hub_event_id) where hub_event_id is not null"); + await pool.query("create index if not exists ai_workspace_setup_codes_owner_executor_idx on ai_workspace_setup_codes(owner_key, executor_id, created_at desc)"); + await pool.query("create index if not exists ai_workspace_setup_codes_expires_idx on ai_workspace_setup_codes(status, expires_at)"); } function sanitizeExecutorCommand(payload, { partial }) { @@ -3412,8 +3644,12 @@ function toIso(value) { } function badRequest(message) { + return httpError(message, 400); +} + +function httpError(message, status = 500) { const error = new Error(message); - error.status = 400; + error.status = status; return error; } @@ -3585,6 +3821,10 @@ function readConfig() { optionalString(process.env.NDC_AI_WORKSPACE_HUB_HTTP_URL) || optionalString(process.env.AI_WORKSPACE_HUB_HTTP_URL) || httpUrlFromWebSocketUrl(hubWebSocketUrl); + const setupGatewayUrl = + optionalString(process.env.AI_WORKSPACE_SETUP_GATEWAY_URL) || + optionalString(process.env.NDC_AI_WORKSPACE_SETUP_GATEWAY_URL) || + httpUrlFromWebSocketUrl(hubWebSocketUrl); const explicitHubAccessToken = optionalString(process.env.AI_WORKSPACE_HUB_TOKEN) || optionalString(process.env.NDC_AI_WORKSPACE_HUB_TOKEN) || @@ -3633,6 +3873,18 @@ function readConfig() { databasePoolSize: Number(process.env.AI_WORKSPACE_ASSISTANT_DATABASE_POOL_SIZE || "10"), hubWebSocketUrl, hubInternalHttpUrl: hubInternalHttpUrl.replace(/\/+$/, ""), + setupGatewayUrl: setupGatewayUrl.replace(/\/+$/, ""), + bridgePackageName: + optionalString(process.env.AI_WORKSPACE_BRIDGE_PACKAGE_NAME) || + optionalString(process.env.NDC_AI_WORKSPACE_BRIDGE_PACKAGE_NAME) || + AI_WORKSPACE_BRIDGE_PACKAGE_NAME, + setupCodeTtlSeconds: sanitizeInteger( + process.env.AI_WORKSPACE_SETUP_CODE_TTL_SECONDS || + process.env.NDC_AI_WORKSPACE_SETUP_CODE_TTL_SECONDS, + 15 * 60, + 60, + 24 * 60 * 60 + ), hubInternalAccessToken: explicitHubAccessToken || (isDeployedPublicHubUrl(hubInternalHttpUrl) ? "" : sharedInternalAccessToken), ontologyLauncherBaseUrl: ontologyLauncherBaseUrl.replace(/\/+$/, ""), diff --git a/services/ai-workspace-hub/src/server.mjs b/services/ai-workspace-hub/src/server.mjs index adac661..9380e24 100644 --- a/services/ai-workspace-hub/src/server.mjs +++ b/services/ai-workspace-hub/src/server.mjs @@ -67,6 +67,7 @@ app.post("/api/ai-workspace/hub/v1/agents/:pairingCode/dispatch", requireInterna }); app.post("/api/ai-workspace/assistant/v1/actions", requireInternalApi, asyncRoute(proxyAssistantActions)); +app.post("/api/ai-workspace/setup-codes/redeem", asyncRoute(proxySetupCodeRedeem)); app.post("/api/ai-workspace/hub/v1/assistant-relays/:relayId/actions", requireInternalApi, asyncRoute(callAssistantRelayAction)); app.post("/api/ai-workspace/hub/v1/assistant-relays/:relayId/poll", requireInternalApi, asyncRoute(pollAssistantRelay)); @@ -292,6 +293,29 @@ async function proxyAssistantActions(req, res) { res.send(text); } +async function proxySetupCodeRedeem(req, res) { + if (!config.assistantInternalUrl || !config.assistantInternalAccessToken) { + res.status(503).json({ ok: false, error: "assistant_setup_proxy_not_configured" }); + return; + } + const targetUrl = `${config.assistantInternalUrl.replace(/\/+$/, "")}/api/ai-workspace/assistant/v1/setup-codes/redeem`; + const upstream = await fetch(targetUrl, { + method: "POST", + redirect: "manual", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${config.assistantInternalAccessToken}`, + }, + body: JSON.stringify(req.body || {}), + }); + const contentType = upstream.headers.get("content-type") || "application/json; charset=utf-8"; + const text = await upstream.text(); + res.status(upstream.status); + res.setHeader("content-type", contentType); + res.send(text); +} + async function callAssistantRelayAction(req, res) { const relayId = cleanRelayId(req.params.relayId); if (!relayId) {