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

View File

@ -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

View File

@ -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;
});

View File

@ -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"
}
}

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(/\/+$/, ""),

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) {