diff --git a/services/ai-workspace-assistant/src/assets/ai-workspace-npm/README.md b/services/ai-workspace-assistant/src/assets/ai-workspace-npm/README.md index fc0a7e4..24eb0f5 100644 --- a/services/ai-workspace-assistant/src/assets/ai-workspace-npm/README.md +++ b/services/ai-workspace-assistant/src/assets/ai-workspace-npm/README.md @@ -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 --gateway https://ai-hub.nodedc.ru +npx --yes --package https://ai-hub.nodedc.ru/api/ai-workspace/bridge-package.tgz ai-workspace-bridge setup --gateway https://ai-hub.nodedc.ru ``` Commands: diff --git a/services/ai-workspace-assistant/src/assets/ai-workspace-npm/bin/nodedc-ai-workspace-bridge.mjs b/services/ai-workspace-assistant/src/assets/ai-workspace-npm/bin/nodedc-ai-workspace-bridge.mjs index d639063..4db6c5f 100755 --- a/services/ai-workspace-assistant/src/assets/ai-workspace-npm/bin/nodedc-ai-workspace-bridge.mjs +++ b/services/ai-workspace-assistant/src/assets/ai-workspace-npm/bin/nodedc-ai-workspace-bridge.mjs @@ -370,8 +370,8 @@ Usage: ai-workspace-bridge stop [--install-root ] ai-workspace-bridge logs [--install-root ] -Registry form: - npx --yes ${PACKAGE_NAME} setup --gateway ${DEFAULT_GATEWAY} +AI Hub tarball form: + npx --yes --package ${DEFAULT_GATEWAY}/api/ai-workspace/bridge-package.tgz ai-workspace-bridge setup --gateway ${DEFAULT_GATEWAY} `); } diff --git a/services/ai-workspace-assistant/src/server.mjs b/services/ai-workspace-assistant/src/server.mjs index fde3a09..ee07849 100644 --- a/services/ai-workspace-assistant/src/server.mjs +++ b/services/ai-workspace-assistant/src/server.mjs @@ -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) || diff --git a/services/ai-workspace-hub/src/server.mjs b/services/ai-workspace-hub/src/server.mjs index b835188..ec10e98 100644 --- a/services/ai-workspace-hub/src/server.mjs +++ b/services/ai-workspace-hub/src/server.mjs @@ -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) {