feat: add shared ai workspace agent installer
This commit is contained in:
parent
e208b9f659
commit
b59fa8d9ef
|
|
@ -162,6 +162,7 @@ services:
|
|||
PORT: 18082
|
||||
DATABASE_URL: postgres://${AI_WORKSPACE_PG_USER:-nodedc_ai_workspace}:${AI_WORKSPACE_PG_PASS:-nodedc_ai_workspace}@ai-workspace-postgres:5432/${AI_WORKSPACE_PG_DB:-nodedc_ai_workspace}
|
||||
AI_WORKSPACE_ASSISTANT_TOKEN: ${AI_WORKSPACE_ASSISTANT_TOKEN:-dev-ai-workspace-assistant-token}
|
||||
AI_WORKSPACE_HUB_PUBLIC_URL: ${AI_WORKSPACE_HUB_PUBLIC_URL:-ws://host.docker.internal:18081/api/ai-workspace/hub}
|
||||
expose:
|
||||
- "18082"
|
||||
ports:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import express from "express";
|
||||
import { randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { Pool } from "pg";
|
||||
|
||||
|
|
@ -83,6 +84,42 @@ app.post("/api/ai-workspace/assistant/v1/executors/:executorId/select", requireI
|
|||
res.json({ ok: true, owner: publicOwner(owner), selectedExecutorId: executor.id, executor });
|
||||
}));
|
||||
|
||||
app.get("/api/ai-workspace/assistant/v1/executors/:executorId/agent/windows.ps1", 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).send("executor_not_found");
|
||||
return;
|
||||
}
|
||||
const installerPort = installerPortFromQuery(req.query.port);
|
||||
if (!installerPort) {
|
||||
res.status(400).send("installer_port_invalid");
|
||||
return;
|
||||
}
|
||||
|
||||
const templateUrl = new URL("./templates/install-windows.ps1", import.meta.url);
|
||||
let source = await readFile(templateUrl, "utf8");
|
||||
source = source
|
||||
.replace('[string]$HubUrl = "",', `[string]$HubUrl = ${psSingle(config.hubWebSocketUrl)},`)
|
||||
.replace('[string]$HubFallbackUrls = "",', `[string]$HubFallbackUrls = ${psSingle(config.hubFallbackWebSocketUrls.join(","))},`)
|
||||
.replace('[string]$PairingCode = "",', `[string]$PairingCode = ${psSingle(executor.pairingCode || "")},`)
|
||||
.replace('[string]$MachineName = "",', `[string]$MachineName = ${psSingle(executor.name)},`)
|
||||
.replace('[int]$Port = 8787,', `[int]$Port = ${installerPort},`)
|
||||
.replace('[string]$Workspace = "",', `[string]$Workspace = ${psSingle(executor.workspacePath || "")},`);
|
||||
|
||||
const installerHash = createHash("sha256").update(source).digest("hex");
|
||||
const safeName = optionalString(executor.name)?.replace(/[^A-Za-z0-9._-]+/g, "_") || "NDC";
|
||||
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-Installer-SHA256", installerHash);
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${safeName}_BRIDGE-agent-install.ps1"`);
|
||||
res.send(source);
|
||||
}));
|
||||
|
||||
app.get("/api/ai-workspace/assistant/v1/threads", requireInternalApi, asyncRoute(async (req, res) => {
|
||||
const owner = getRequestOwner(req);
|
||||
const surface = req.query.surface ? sanitizeSurface(req.query.surface) : null;
|
||||
|
|
@ -174,6 +211,9 @@ async function listExecutors(owner) {
|
|||
}
|
||||
|
||||
async function createExecutor(owner, command) {
|
||||
if (command.connectionMode === "hub" && !command.pairingCode) {
|
||||
command.pairingCode = makePairingCode();
|
||||
}
|
||||
const result = await pool.query(
|
||||
`insert into ai_workspace_executors (
|
||||
id,
|
||||
|
|
@ -498,7 +538,7 @@ async function migrate() {
|
|||
pairing_code text,
|
||||
model text,
|
||||
account_label text,
|
||||
capabilities jsonb not null default '{}'::jsonb,
|
||||
capabilities jsonb not null default '[]'::jsonb,
|
||||
status text not null default 'unknown',
|
||||
status_detail text,
|
||||
last_seen_at timestamptz,
|
||||
|
|
@ -599,7 +639,7 @@ function sanitizeExecutorCommand(payload, { partial }) {
|
|||
copyOptionalString(command, "model", source.model);
|
||||
copyOptionalString(command, "accountLabel", source.accountLabel || source.account_label);
|
||||
if (!partial || Object.hasOwn(source, "capabilities")) {
|
||||
command.capabilities = isJsonContainer(source.capabilities) ? source.capabilities : {};
|
||||
command.capabilities = sanitizeCapabilities(source.capabilities);
|
||||
}
|
||||
if (!partial || Object.hasOwn(source, "status")) {
|
||||
command.status = sanitizeEnum(source.status || "unknown", SUPPORTED_EXECUTOR_STATUSES, "unsupported_executor_status");
|
||||
|
|
@ -666,7 +706,7 @@ function getRequestOwner(req) {
|
|||
throw badRequest("ai_workspace_owner_required");
|
||||
}
|
||||
return {
|
||||
key: userId ? `user:${userId}` : `email:${email}`,
|
||||
key: email ? `email:${email}` : `user:${userId}`,
|
||||
userId,
|
||||
email,
|
||||
};
|
||||
|
|
@ -695,7 +735,7 @@ function toExecutor(row) {
|
|||
pairingCode: row.pairing_code,
|
||||
model: row.model,
|
||||
accountLabel: row.account_label,
|
||||
capabilities: row.capabilities || {},
|
||||
capabilities: sanitizeCapabilities(row.capabilities),
|
||||
status: row.status,
|
||||
statusDetail: row.status_detail,
|
||||
lastSeenAt: toIso(row.last_seen_at),
|
||||
|
|
@ -787,6 +827,17 @@ function sanitizeToolPacks(value) {
|
|||
});
|
||||
}
|
||||
|
||||
function sanitizeCapabilities(value) {
|
||||
const items = Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(value?.items)
|
||||
? value.items
|
||||
: isPlainObject(value)
|
||||
? Object.entries(value).filter(([, enabled]) => enabled === true).map(([key]) => key)
|
||||
: [];
|
||||
return Array.from(new Set(items.map(normalizeKey).filter(Boolean)));
|
||||
}
|
||||
|
||||
function sanitizeEnum(value, supported, errorMessage) {
|
||||
const key = normalizeKey(value);
|
||||
if (!supported.has(key)) {
|
||||
|
|
@ -801,6 +852,32 @@ function sanitizeLimit(value, fallback, max) {
|
|||
return Math.min(Math.max(Math.trunc(limit), 1), max);
|
||||
}
|
||||
|
||||
function normalizePairingCode(value) {
|
||||
return String(value || "").replace(/[^A-Za-z0-9]/g, "").toUpperCase().slice(0, 32);
|
||||
}
|
||||
|
||||
function cleanPairingCode(value) {
|
||||
const normalized = normalizePairingCode(value);
|
||||
if (!normalized) return "";
|
||||
return normalized.replace(/(.{4})(?=.)/g, "$1-");
|
||||
}
|
||||
|
||||
function makePairingCode() {
|
||||
return cleanPairingCode(randomUUID().replace(/-/g, "").slice(0, 12));
|
||||
}
|
||||
|
||||
function installerPortFromQuery(raw) {
|
||||
const value = optionalString(raw);
|
||||
if (!value) return 8787;
|
||||
if (!/^\d{1,5}$/.test(value)) return null;
|
||||
const port = Number(value);
|
||||
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
|
||||
}
|
||||
|
||||
function psSingle(value) {
|
||||
return `'${String(value || "").replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function sanitizeUuid(value, name) {
|
||||
const text = optionalString(value);
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(text || "")) {
|
||||
|
|
@ -821,7 +898,7 @@ function sanitizeOptionalIso(value, name) {
|
|||
|
||||
function copyOptionalString(target, key, value) {
|
||||
if (value === undefined) return;
|
||||
target[key] = optionalString(value);
|
||||
target[key] = key === "pairingCode" ? cleanPairingCode(value) : optionalString(value);
|
||||
}
|
||||
|
||||
function copyOptionalInteger(target, key, value, min, max) {
|
||||
|
|
@ -862,10 +939,6 @@ function isPlainObject(value) {
|
|||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function isJsonContainer(value) {
|
||||
return Boolean(value && typeof value === "object");
|
||||
}
|
||||
|
||||
function toIso(value) {
|
||||
if (!value) return null;
|
||||
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
|
|
@ -893,6 +966,19 @@ function readConfig() {
|
|||
port: Number(process.env.PORT || process.env.AI_WORKSPACE_ASSISTANT_PORT || "18082"),
|
||||
databaseUrl,
|
||||
databasePoolSize: Number(process.env.AI_WORKSPACE_ASSISTANT_DATABASE_POOL_SIZE || "10"),
|
||||
hubWebSocketUrl:
|
||||
process.env.AI_WORKSPACE_HUB_PUBLIC_URL ||
|
||||
process.env.NDC_AI_WORKSPACE_HUB_URL ||
|
||||
process.env.AI_WORKSPACE_HUB_URL ||
|
||||
"wss://ai-hub.nodedc.ru/api/ai-workspace/hub",
|
||||
hubFallbackWebSocketUrls: String(
|
||||
process.env.AI_WORKSPACE_HUB_FALLBACK_URLS ||
|
||||
process.env.NDC_AI_WORKSPACE_HUB_FALLBACK_URLS ||
|
||||
""
|
||||
)
|
||||
.split(/[\n,;]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
internalAccessToken:
|
||||
process.env.AI_WORKSPACE_ASSISTANT_TOKEN ||
|
||||
process.env.NODEDC_INTERNAL_ACCESS_TOKEN ||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue