fix: serve AI Workspace bridge package from hub
This commit is contained in:
parent
f35cd34167
commit
5953b1bd0e
|
|
@ -2,10 +2,10 @@
|
||||||
|
|
||||||
Installs the remote Codex worker used by NODE.DC AI Workspace.
|
Installs the remote Codex worker used by NODE.DC AI Workspace.
|
||||||
|
|
||||||
Registry form:
|
AI Hub tarball form:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npx --yes @nodedc/ai-workspace-bridge setup <setup-code> --gateway https://ai-hub.nodedc.ru
|
npx --yes --package https://ai-hub.nodedc.ru/api/ai-workspace/bridge-package.tgz ai-workspace-bridge setup <setup-code> --gateway https://ai-hub.nodedc.ru
|
||||||
```
|
```
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
|
|
|
||||||
|
|
@ -370,8 +370,8 @@ Usage:
|
||||||
ai-workspace-bridge stop [--install-root <path>]
|
ai-workspace-bridge stop [--install-root <path>]
|
||||||
ai-workspace-bridge logs [--install-root <path>]
|
ai-workspace-bridge logs [--install-root <path>]
|
||||||
|
|
||||||
Registry form:
|
AI Hub tarball form:
|
||||||
npx --yes ${PACKAGE_NAME} setup <setup-code> --gateway ${DEFAULT_GATEWAY}
|
npx --yes --package ${DEFAULT_GATEWAY}/api/ai-workspace/bridge-package.tgz ai-workspace-bridge setup <setup-code> --gateway ${DEFAULT_GATEWAY}
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readdir, readFile, stat } from "node:fs/promises";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
|
import pathModule from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { gzipSync } from "node:zlib";
|
||||||
import { Pool } from "pg";
|
import { Pool } from "pg";
|
||||||
import { handleAssistantCallerRequest } from "../../ontology-core/src/assistant-action-caller.mjs";
|
import { handleAssistantCallerRequest } from "../../ontology-core/src/assistant-action-caller.mjs";
|
||||||
import { loadCatalog } from "../../ontology-core/src/catalog.mjs";
|
import { loadCatalog } from "../../ontology-core/src/catalog.mjs";
|
||||||
|
|
@ -15,7 +18,11 @@ 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_BRIDGE_PACKAGE_NAME = "@nodedc/ai-workspace-bridge";
|
||||||
|
const AI_WORKSPACE_BRIDGE_PACKAGE_BIN = "ai-workspace-bridge";
|
||||||
|
const AI_WORKSPACE_BRIDGE_PACKAGE_PUBLIC_PATH = "/api/ai-workspace/bridge-package.tgz";
|
||||||
const AI_WORKSPACE_SETUP_CODE_PREFIX = "ndcaws";
|
const AI_WORKSPACE_SETUP_CODE_PREFIX = "ndcaws";
|
||||||
|
const TAR_BLOCK_SIZE = 512;
|
||||||
|
let cachedBridgePackageTarball = null;
|
||||||
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",
|
||||||
|
|
@ -423,6 +430,7 @@ app.post("/api/ai-workspace/assistant/v1/executors/:executorId/agent/setup-comma
|
||||||
install: {
|
install: {
|
||||||
command,
|
command,
|
||||||
packageName: config.bridgePackageName,
|
packageName: config.bridgePackageName,
|
||||||
|
packageSpec: config.bridgePackageSpec,
|
||||||
gatewayUrl: config.setupGatewayUrl,
|
gatewayUrl: config.setupGatewayUrl,
|
||||||
expiresAt: setupCode.expiresAt,
|
expiresAt: setupCode.expiresAt,
|
||||||
},
|
},
|
||||||
|
|
@ -433,6 +441,18 @@ app.post("/api/ai-workspace/assistant/v1/executors/:executorId/agent/setup-comma
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
app.get("/api/ai-workspace/assistant/v1/bridge-package.tgz", requireInternalApi, asyncRoute(async (_req, res) => {
|
||||||
|
const bridgePackage = await getBridgePackageTarball();
|
||||||
|
res.setHeader("Content-Type", "application/octet-stream");
|
||||||
|
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate");
|
||||||
|
res.setHeader("Pragma", "no-cache");
|
||||||
|
res.setHeader("Expires", "0");
|
||||||
|
res.setHeader("Surrogate-Control", "no-store");
|
||||||
|
res.setHeader("X-AI-Workspace-Bridge-Package-SHA256", bridgePackage.sha256);
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="${bridgePackage.filename}"`);
|
||||||
|
res.send(bridgePackage.buffer);
|
||||||
|
}));
|
||||||
|
|
||||||
app.post("/api/ai-workspace/assistant/v1/setup-codes/redeem", requireInternalApi, asyncRoute(async (req, res) => {
|
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);
|
const setupCode = optionalString(req.body?.setupCode || req.body?.setup_code || req.query?.setupCode || req.query?.setup_code);
|
||||||
if (!setupCode) {
|
if (!setupCode) {
|
||||||
|
|
@ -964,7 +984,9 @@ function buildBridgeSetupCommand(code) {
|
||||||
const parts = [
|
const parts = [
|
||||||
"npx",
|
"npx",
|
||||||
"--yes",
|
"--yes",
|
||||||
config.bridgePackageName,
|
"--package",
|
||||||
|
config.bridgePackageSpec,
|
||||||
|
AI_WORKSPACE_BRIDGE_PACKAGE_BIN,
|
||||||
"setup",
|
"setup",
|
||||||
code,
|
code,
|
||||||
"--gateway",
|
"--gateway",
|
||||||
|
|
@ -973,6 +995,127 @@ function buildBridgeSetupCommand(code) {
|
||||||
return parts.join(" ");
|
return parts.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getBridgePackageTarball() {
|
||||||
|
if (cachedBridgePackageTarball) return cachedBridgePackageTarball;
|
||||||
|
|
||||||
|
const packageRoot = fileURLToPath(new URL("./assets/ai-workspace-npm/", import.meta.url));
|
||||||
|
const packageJson = JSON.parse(await readFile(pathModule.join(packageRoot, "package.json"), "utf8"));
|
||||||
|
const files = await collectBridgePackageFiles(packageRoot);
|
||||||
|
const tarBuffer = await buildBridgePackageTarBuffer(packageRoot, files);
|
||||||
|
const buffer = gzipSync(tarBuffer, { level: 9 });
|
||||||
|
cachedBridgePackageTarball = {
|
||||||
|
buffer,
|
||||||
|
sha256: createHash("sha256").update(buffer).digest("hex"),
|
||||||
|
filename: bridgePackageTarballFilename(packageJson.name, packageJson.version),
|
||||||
|
};
|
||||||
|
return cachedBridgePackageTarball;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectBridgePackageFiles(root, relDir = "") {
|
||||||
|
const currentDir = pathModule.join(root, relDir);
|
||||||
|
const entries = await readdir(currentDir, { withFileTypes: true });
|
||||||
|
const files = [];
|
||||||
|
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||||
|
if (shouldSkipBridgePackageEntry(entry.name)) continue;
|
||||||
|
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...await collectBridgePackageFiles(root, relPath));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.isFile()) files.push(relPath);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSkipBridgePackageEntry(name) {
|
||||||
|
return (
|
||||||
|
name === ".DS_Store" ||
|
||||||
|
name === "__MACOSX" ||
|
||||||
|
name === "node_modules" ||
|
||||||
|
name.endsWith(".tgz")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildBridgePackageTarBuffer(root, files) {
|
||||||
|
const chunks = [];
|
||||||
|
for (const relPath of files) {
|
||||||
|
const fullPath = pathModule.join(root, ...relPath.split("/"));
|
||||||
|
const fileStat = await stat(fullPath);
|
||||||
|
if (!fileStat.isFile()) continue;
|
||||||
|
const body = await readFile(fullPath);
|
||||||
|
const mode = relPath.startsWith("bin/") ? 0o755 : 0o644;
|
||||||
|
chunks.push(buildTarHeader({
|
||||||
|
name: `package/${relPath}`,
|
||||||
|
mode,
|
||||||
|
size: body.length,
|
||||||
|
type: "0",
|
||||||
|
}));
|
||||||
|
chunks.push(body);
|
||||||
|
const padding = (TAR_BLOCK_SIZE - (body.length % TAR_BLOCK_SIZE)) % TAR_BLOCK_SIZE;
|
||||||
|
if (padding) chunks.push(Buffer.alloc(padding));
|
||||||
|
}
|
||||||
|
chunks.push(Buffer.alloc(TAR_BLOCK_SIZE * 2));
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTarHeader({ name, mode, size, type }) {
|
||||||
|
const header = Buffer.alloc(TAR_BLOCK_SIZE);
|
||||||
|
const splitName = splitTarName(name);
|
||||||
|
writeTarString(header, 0, 100, splitName.name);
|
||||||
|
writeTarOctal(header, 100, 8, mode);
|
||||||
|
writeTarOctal(header, 108, 8, 0);
|
||||||
|
writeTarOctal(header, 116, 8, 0);
|
||||||
|
writeTarOctal(header, 124, 12, size);
|
||||||
|
writeTarOctal(header, 136, 12, 0);
|
||||||
|
header.fill(0x20, 148, 156);
|
||||||
|
writeTarString(header, 156, 1, type || "0");
|
||||||
|
writeTarString(header, 257, 6, "ustar");
|
||||||
|
writeTarString(header, 263, 2, "00");
|
||||||
|
writeTarString(header, 265, 32, "nodedc");
|
||||||
|
writeTarString(header, 297, 32, "nodedc");
|
||||||
|
if (splitName.prefix) writeTarString(header, 345, 155, splitName.prefix);
|
||||||
|
writeTarChecksum(header, header.reduce((sum, value) => sum + value, 0));
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTarName(name) {
|
||||||
|
if (Buffer.byteLength(name) <= 100) return { name, prefix: "" };
|
||||||
|
let slashIndex = name.lastIndexOf("/");
|
||||||
|
while (slashIndex > 0) {
|
||||||
|
const prefix = name.slice(0, slashIndex);
|
||||||
|
const entryName = name.slice(slashIndex + 1);
|
||||||
|
if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(entryName) <= 100) {
|
||||||
|
return { name: entryName, prefix };
|
||||||
|
}
|
||||||
|
slashIndex = name.lastIndexOf("/", slashIndex - 1);
|
||||||
|
}
|
||||||
|
throw new Error(`bridge_package_path_too_long:${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTarString(buffer, offset, length, value) {
|
||||||
|
buffer.write(String(value || ""), offset, length, "ascii");
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTarOctal(buffer, offset, length, value) {
|
||||||
|
const text = Math.trunc(Number(value) || 0).toString(8).padStart(length - 1, "0").slice(-(length - 1));
|
||||||
|
buffer.write(text, offset, length - 1, "ascii");
|
||||||
|
buffer[offset + length - 1] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTarChecksum(buffer, checksum) {
|
||||||
|
const text = Math.trunc(Number(checksum) || 0).toString(8).padStart(6, "0").slice(-6);
|
||||||
|
buffer.write(text, 148, 6, "ascii");
|
||||||
|
buffer[154] = 0;
|
||||||
|
buffer[155] = 0x20;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bridgePackageTarballFilename(packageName, version) {
|
||||||
|
const rawName = String(packageName || AI_WORKSPACE_BRIDGE_PACKAGE_NAME).replace(/^@[^/]+\//, "");
|
||||||
|
const safeName = rawName.replace(/[^A-Za-z0-9._-]+/g, "-") || "ai-workspace-bridge";
|
||||||
|
const safeVersion = String(version || "0.0.0").replace(/[^A-Za-z0-9._-]+/g, "-");
|
||||||
|
return `${safeName}-${safeVersion}.tgz`;
|
||||||
|
}
|
||||||
|
|
||||||
function makeAiWorkspaceSetupCode() {
|
function makeAiWorkspaceSetupCode() {
|
||||||
return `${AI_WORKSPACE_SETUP_CODE_PREFIX}_${randomBytes(18).toString("base64url")}`;
|
return `${AI_WORKSPACE_SETUP_CODE_PREFIX}_${randomBytes(18).toString("base64url")}`;
|
||||||
}
|
}
|
||||||
|
|
@ -4223,6 +4366,11 @@ function readConfig() {
|
||||||
optionalString(process.env.AI_WORKSPACE_OPS_GATEWAY_BASE_URL) ||
|
optionalString(process.env.AI_WORKSPACE_OPS_GATEWAY_BASE_URL) ||
|
||||||
optionalString(process.env.NDC_AI_WORKSPACE_OPS_GATEWAY_BASE_URL) ||
|
optionalString(process.env.NDC_AI_WORKSPACE_OPS_GATEWAY_BASE_URL) ||
|
||||||
"";
|
"";
|
||||||
|
const normalizedSetupGatewayUrl = setupGatewayUrl.replace(/\/+$/, "");
|
||||||
|
const bridgePackageSpec =
|
||||||
|
optionalString(process.env.AI_WORKSPACE_BRIDGE_PACKAGE_SPEC) ||
|
||||||
|
optionalString(process.env.NDC_AI_WORKSPACE_BRIDGE_PACKAGE_SPEC) ||
|
||||||
|
`${normalizedSetupGatewayUrl}${AI_WORKSPACE_BRIDGE_PACKAGE_PUBLIC_PATH}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nodedcEnv,
|
nodedcEnv,
|
||||||
|
|
@ -4231,7 +4379,8 @@ 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(/\/+$/, ""),
|
setupGatewayUrl: normalizedSetupGatewayUrl,
|
||||||
|
bridgePackageSpec,
|
||||||
bridgePackageName:
|
bridgePackageName:
|
||||||
optionalString(process.env.AI_WORKSPACE_BRIDGE_PACKAGE_NAME) ||
|
optionalString(process.env.AI_WORKSPACE_BRIDGE_PACKAGE_NAME) ||
|
||||||
optionalString(process.env.NDC_AI_WORKSPACE_BRIDGE_PACKAGE_NAME) ||
|
optionalString(process.env.NDC_AI_WORKSPACE_BRIDGE_PACKAGE_NAME) ||
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,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/setup-codes/redeem", asyncRoute(proxySetupCodeRedeem));
|
||||||
|
app.get("/api/ai-workspace/bridge-package.tgz", asyncRoute(proxyBridgePackage));
|
||||||
|
|
||||||
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));
|
||||||
|
|
@ -324,6 +325,38 @@ async function proxySetupCodeRedeem(req, res) {
|
||||||
res.send(text);
|
res.send(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function proxyBridgePackage(_req, res) {
|
||||||
|
if (!config.assistantInternalUrl || !config.assistantInternalAccessToken) {
|
||||||
|
res.status(503).json({ ok: false, error: "assistant_bridge_package_proxy_not_configured" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetUrl = `${config.assistantInternalUrl.replace(/\/+$/, "")}/api/ai-workspace/assistant/v1/bridge-package.tgz`;
|
||||||
|
const upstream = await fetch(targetUrl, {
|
||||||
|
method: "GET",
|
||||||
|
redirect: "manual",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/octet-stream",
|
||||||
|
Authorization: `Bearer ${config.assistantInternalAccessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const contentType = upstream.headers.get("content-type") || "application/octet-stream";
|
||||||
|
const body = Buffer.from(await upstream.arrayBuffer());
|
||||||
|
res.status(upstream.status);
|
||||||
|
res.setHeader("content-type", contentType);
|
||||||
|
for (const header of [
|
||||||
|
"cache-control",
|
||||||
|
"pragma",
|
||||||
|
"expires",
|
||||||
|
"surrogate-control",
|
||||||
|
"content-disposition",
|
||||||
|
"x-ai-workspace-bridge-package-sha256",
|
||||||
|
]) {
|
||||||
|
const value = upstream.headers.get(header);
|
||||||
|
if (value) res.setHeader(header, value);
|
||||||
|
}
|
||||||
|
res.send(body);
|
||||||
|
}
|
||||||
|
|
||||||
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