feat: add AI Workspace npm bridge setup

This commit is contained in:
Codex
2026-06-24 20:40:12 +03:00
parent 92feff97e6
commit 999fe4d906
7 changed files with 4479 additions and 2 deletions
@@ -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 <setup-code> --gateway https://ai-hub.nodedc.ru
```
Commands:
```sh
ai-workspace-bridge setup <setup-code> [--gateway <url>] [--install-root <path>] [--workspace <path>] [--port <port>]
ai-workspace-bridge status [--install-root <path>]
ai-workspace-bridge doctor [--install-root <path>]
ai-workspace-bridge start [--install-root <path>]
ai-workspace-bridge stop [--install-root <path>]
ai-workspace-bridge logs [--install-root <path>]
```
The setup code is one-time and short-lived. The CLI does not print the redeemed pairing code or MCP tokens.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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 <setup-code>`);
}
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 <setup-code> [--gateway <url>] [--install-root <path>] [--workspace <path>] [--port <port>]
ai-workspace-bridge status [--install-root <path>]
ai-workspace-bridge doctor [--install-root <path>]
ai-workspace-bridge start [--install-root <path>]
ai-workspace-bridge stop [--install-root <path>]
ai-workspace-bridge logs [--install-root <path>]
Registry form:
npx --yes ${PACKAGE_NAME} setup <setup-code> --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, `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>${label}</string>
<key>ProgramArguments</key><array><string>${xmlEscape(process.execPath)}</string><string>${xmlEscape(startScriptPath)}</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>${xmlEscape(path.join(path.dirname(startScriptPath), "logs", "launchd.out.log"))}</string>
<key>StandardErrorPath</key><string>${xmlEscape(path.join(path.dirname(startScriptPath), "logs", "launchd.err.log"))}</string>
</dict></plist>
`, "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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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;
});
@@ -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"
}
}
+254 -2
View File
@@ -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(/\/+$/, ""),
+24
View File
@@ -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) {