feat: add shared ai workspace agent installer
This commit is contained in:
parent
e208b9f659
commit
b59fa8d9ef
|
|
@ -162,6 +162,7 @@ services:
|
||||||
PORT: 18082
|
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}
|
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_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:
|
expose:
|
||||||
- "18082"
|
- "18082"
|
||||||
ports:
|
ports:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import express from "express";
|
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 { createServer } from "node:http";
|
||||||
import { Pool } from "pg";
|
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 });
|
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) => {
|
app.get("/api/ai-workspace/assistant/v1/threads", requireInternalApi, asyncRoute(async (req, res) => {
|
||||||
const owner = getRequestOwner(req);
|
const owner = getRequestOwner(req);
|
||||||
const surface = req.query.surface ? sanitizeSurface(req.query.surface) : null;
|
const surface = req.query.surface ? sanitizeSurface(req.query.surface) : null;
|
||||||
|
|
@ -174,6 +211,9 @@ async function listExecutors(owner) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createExecutor(owner, command) {
|
async function createExecutor(owner, command) {
|
||||||
|
if (command.connectionMode === "hub" && !command.pairingCode) {
|
||||||
|
command.pairingCode = makePairingCode();
|
||||||
|
}
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`insert into ai_workspace_executors (
|
`insert into ai_workspace_executors (
|
||||||
id,
|
id,
|
||||||
|
|
@ -498,7 +538,7 @@ async function migrate() {
|
||||||
pairing_code text,
|
pairing_code text,
|
||||||
model text,
|
model text,
|
||||||
account_label text,
|
account_label text,
|
||||||
capabilities jsonb not null default '{}'::jsonb,
|
capabilities jsonb not null default '[]'::jsonb,
|
||||||
status text not null default 'unknown',
|
status text not null default 'unknown',
|
||||||
status_detail text,
|
status_detail text,
|
||||||
last_seen_at timestamptz,
|
last_seen_at timestamptz,
|
||||||
|
|
@ -599,7 +639,7 @@ function sanitizeExecutorCommand(payload, { partial }) {
|
||||||
copyOptionalString(command, "model", source.model);
|
copyOptionalString(command, "model", source.model);
|
||||||
copyOptionalString(command, "accountLabel", source.accountLabel || source.account_label);
|
copyOptionalString(command, "accountLabel", source.accountLabel || source.account_label);
|
||||||
if (!partial || Object.hasOwn(source, "capabilities")) {
|
if (!partial || Object.hasOwn(source, "capabilities")) {
|
||||||
command.capabilities = isJsonContainer(source.capabilities) ? source.capabilities : {};
|
command.capabilities = sanitizeCapabilities(source.capabilities);
|
||||||
}
|
}
|
||||||
if (!partial || Object.hasOwn(source, "status")) {
|
if (!partial || Object.hasOwn(source, "status")) {
|
||||||
command.status = sanitizeEnum(source.status || "unknown", SUPPORTED_EXECUTOR_STATUSES, "unsupported_executor_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");
|
throw badRequest("ai_workspace_owner_required");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
key: userId ? `user:${userId}` : `email:${email}`,
|
key: email ? `email:${email}` : `user:${userId}`,
|
||||||
userId,
|
userId,
|
||||||
email,
|
email,
|
||||||
};
|
};
|
||||||
|
|
@ -695,7 +735,7 @@ function toExecutor(row) {
|
||||||
pairingCode: row.pairing_code,
|
pairingCode: row.pairing_code,
|
||||||
model: row.model,
|
model: row.model,
|
||||||
accountLabel: row.account_label,
|
accountLabel: row.account_label,
|
||||||
capabilities: row.capabilities || {},
|
capabilities: sanitizeCapabilities(row.capabilities),
|
||||||
status: row.status,
|
status: row.status,
|
||||||
statusDetail: row.status_detail,
|
statusDetail: row.status_detail,
|
||||||
lastSeenAt: toIso(row.last_seen_at),
|
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) {
|
function sanitizeEnum(value, supported, errorMessage) {
|
||||||
const key = normalizeKey(value);
|
const key = normalizeKey(value);
|
||||||
if (!supported.has(key)) {
|
if (!supported.has(key)) {
|
||||||
|
|
@ -801,6 +852,32 @@ function sanitizeLimit(value, fallback, max) {
|
||||||
return Math.min(Math.max(Math.trunc(limit), 1), 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) {
|
function sanitizeUuid(value, name) {
|
||||||
const text = optionalString(value);
|
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 || "")) {
|
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) {
|
function copyOptionalString(target, key, value) {
|
||||||
if (value === undefined) return;
|
if (value === undefined) return;
|
||||||
target[key] = optionalString(value);
|
target[key] = key === "pairingCode" ? cleanPairingCode(value) : optionalString(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyOptionalInteger(target, key, value, min, max) {
|
function copyOptionalInteger(target, key, value, min, max) {
|
||||||
|
|
@ -862,10 +939,6 @@ function isPlainObject(value) {
|
||||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isJsonContainer(value) {
|
|
||||||
return Boolean(value && typeof value === "object");
|
|
||||||
}
|
|
||||||
|
|
||||||
function toIso(value) {
|
function toIso(value) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
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"),
|
port: Number(process.env.PORT || process.env.AI_WORKSPACE_ASSISTANT_PORT || "18082"),
|
||||||
databaseUrl,
|
databaseUrl,
|
||||||
databasePoolSize: Number(process.env.AI_WORKSPACE_ASSISTANT_DATABASE_POOL_SIZE || "10"),
|
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:
|
internalAccessToken:
|
||||||
process.env.AI_WORKSPACE_ASSISTANT_TOKEN ||
|
process.env.AI_WORKSPACE_ASSISTANT_TOKEN ||
|
||||||
process.env.NODEDC_INTERNAL_ACCESS_TOKEN ||
|
process.env.NODEDC_INTERNAL_ACCESS_TOKEN ||
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue