feat: add shared ai workspace agent installer

This commit is contained in:
Codex 2026-06-09 13:22:38 +03:00
parent e208b9f659
commit b59fa8d9ef
3 changed files with 3905 additions and 10 deletions

View File

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

View File

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