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.
|
||||
|
||||
Registry form:
|
||||
AI Hub tarball form:
|
||||
|
||||
```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:
|
||||
|
|
|
|||
|
|
@ -370,8 +370,8 @@ Usage:
|
|||
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}
|
||||
AI Hub tarball form:
|
||||
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 { 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 pathModule from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import { Pool } from "pg";
|
||||
import { handleAssistantCallerRequest } from "../../ontology-core/src/assistant-action-caller.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_RUN_STATUSES = new Set(["running", "completed", "failed", "timeout"]);
|
||||
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 TAR_BLOCK_SIZE = 512;
|
||||
let cachedBridgePackageTarball = null;
|
||||
const ASSISTANT_ACTION_TOOL_PROFILE_BASE = {
|
||||
schemaVersion: "ai-workspace.assistant-actions.v1",
|
||||
endpoint: "/api/ai-workspace/assistant/v1/actions",
|
||||
|
|
@ -423,6 +430,7 @@ app.post("/api/ai-workspace/assistant/v1/executors/:executorId/agent/setup-comma
|
|||
install: {
|
||||
command,
|
||||
packageName: config.bridgePackageName,
|
||||
packageSpec: config.bridgePackageSpec,
|
||||
gatewayUrl: config.setupGatewayUrl,
|
||||
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) => {
|
||||
const setupCode = optionalString(req.body?.setupCode || req.body?.setup_code || req.query?.setupCode || req.query?.setup_code);
|
||||
if (!setupCode) {
|
||||
|
|
@ -964,7 +984,9 @@ function buildBridgeSetupCommand(code) {
|
|||
const parts = [
|
||||
"npx",
|
||||
"--yes",
|
||||
config.bridgePackageName,
|
||||
"--package",
|
||||
config.bridgePackageSpec,
|
||||
AI_WORKSPACE_BRIDGE_PACKAGE_BIN,
|
||||
"setup",
|
||||
code,
|
||||
"--gateway",
|
||||
|
|
@ -973,6 +995,127 @@ function buildBridgeSetupCommand(code) {
|
|||
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() {
|
||||
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.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 {
|
||||
nodedcEnv,
|
||||
|
|
@ -4231,7 +4379,8 @@ function readConfig() {
|
|||
databasePoolSize: Number(process.env.AI_WORKSPACE_ASSISTANT_DATABASE_POOL_SIZE || "10"),
|
||||
hubWebSocketUrl,
|
||||
hubInternalHttpUrl: hubInternalHttpUrl.replace(/\/+$/, ""),
|
||||
setupGatewayUrl: setupGatewayUrl.replace(/\/+$/, ""),
|
||||
setupGatewayUrl: normalizedSetupGatewayUrl,
|
||||
bridgePackageSpec,
|
||||
bridgePackageName:
|
||||
optionalString(process.env.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/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/poll", requireInternalApi, asyncRoute(pollAssistantRelay));
|
||||
|
|
@ -324,6 +325,38 @@ async function proxySetupCodeRedeem(req, res) {
|
|||
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) {
|
||||
const relayId = cleanRelayId(req.params.relayId);
|
||||
if (!relayId) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue