fix: serve AI Workspace bridge package from hub

This commit is contained in:
Codex 2026-06-25 09:56:48 +03:00
parent f35cd34167
commit 5953b1bd0e
4 changed files with 189 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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