feat: add AI Workspace npm bridge setup
This commit is contained in:
parent
92feff97e6
commit
999fe4d906
|
|
@ -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, "&")
|
||||||
|
.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;
|
||||||
|
});
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import express from "express";
|
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 { readFile } from "node:fs/promises";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { Pool } from "pg";
|
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_MESSAGE_ROLES = new Set(["user", "assistant", "system", "tool"]);
|
||||||
const SUPPORTED_TOOL_PACKS = new Set(["engine", "ops", "ndc-agent-core", "deploy", "docs"]);
|
const SUPPORTED_TOOL_PACKS = new Set(["engine", "ops", "ndc-agent-core", "deploy", "docs"]);
|
||||||
const SUPPORTED_RUN_STATUSES = new Set(["running", "completed", "failed", "timeout"]);
|
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 = {
|
const ASSISTANT_ACTION_TOOL_PROFILE_BASE = {
|
||||||
schemaVersion: "ai-workspace.assistant-actions.v1",
|
schemaVersion: "ai-workspace.assistant-actions.v1",
|
||||||
endpoint: "/api/ai-workspace/assistant/v1/actions",
|
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);
|
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) => {
|
app.get("/api/ai-workspace/assistant/v1/threads", requireInternalApi, asyncRoute(async (req, res) => {
|
||||||
const owner = getRequestOwner(req);
|
const owner = getRequestOwner(req);
|
||||||
const surface = req.query.surface ? sanitizeSurface(req.query.surface) : null;
|
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;
|
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) {
|
async function selectExecutor(owner, executorId) {
|
||||||
const executor = await getExecutor(owner, executorId);
|
const executor = await getExecutor(owner, executorId);
|
||||||
if (!executor) return null;
|
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_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_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)");
|
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_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 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 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 }) {
|
function sanitizeExecutorCommand(payload, { partial }) {
|
||||||
|
|
@ -3412,8 +3644,12 @@ function toIso(value) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function badRequest(message) {
|
function badRequest(message) {
|
||||||
|
return httpError(message, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpError(message, status = 500) {
|
||||||
const error = new Error(message);
|
const error = new Error(message);
|
||||||
error.status = 400;
|
error.status = status;
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3585,6 +3821,10 @@ function readConfig() {
|
||||||
optionalString(process.env.NDC_AI_WORKSPACE_HUB_HTTP_URL) ||
|
optionalString(process.env.NDC_AI_WORKSPACE_HUB_HTTP_URL) ||
|
||||||
optionalString(process.env.AI_WORKSPACE_HUB_HTTP_URL) ||
|
optionalString(process.env.AI_WORKSPACE_HUB_HTTP_URL) ||
|
||||||
httpUrlFromWebSocketUrl(hubWebSocketUrl);
|
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 =
|
const explicitHubAccessToken =
|
||||||
optionalString(process.env.AI_WORKSPACE_HUB_TOKEN) ||
|
optionalString(process.env.AI_WORKSPACE_HUB_TOKEN) ||
|
||||||
optionalString(process.env.NDC_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"),
|
databasePoolSize: Number(process.env.AI_WORKSPACE_ASSISTANT_DATABASE_POOL_SIZE || "10"),
|
||||||
hubWebSocketUrl,
|
hubWebSocketUrl,
|
||||||
hubInternalHttpUrl: hubInternalHttpUrl.replace(/\/+$/, ""),
|
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:
|
hubInternalAccessToken:
|
||||||
explicitHubAccessToken || (isDeployedPublicHubUrl(hubInternalHttpUrl) ? "" : sharedInternalAccessToken),
|
explicitHubAccessToken || (isDeployedPublicHubUrl(hubInternalHttpUrl) ? "" : sharedInternalAccessToken),
|
||||||
ontologyLauncherBaseUrl: ontologyLauncherBaseUrl.replace(/\/+$/, ""),
|
ontologyLauncherBaseUrl: ontologyLauncherBaseUrl.replace(/\/+$/, ""),
|
||||||
|
|
|
||||||
|
|
@ -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/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/actions", requireInternalApi, asyncRoute(callAssistantRelayAction));
|
||||||
app.post("/api/ai-workspace/hub/v1/assistant-relays/:relayId/poll", requireInternalApi, asyncRoute(pollAssistantRelay));
|
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);
|
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) {
|
async function callAssistantRelayAction(req, res) {
|
||||||
const relayId = cleanRelayId(req.params.relayId);
|
const relayId = cleanRelayId(req.params.relayId);
|
||||||
if (!relayId) {
|
if (!relayId) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue