feat: add AI Workspace npm bridge setup
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import express from "express";
|
||||
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { Pool } from "pg";
|
||||
@@ -14,6 +14,8 @@ const SUPPORTED_THREAD_STATES = new Set(["active", "archived"]);
|
||||
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_SETUP_CODE_PREFIX = "ndcaws";
|
||||
const ASSISTANT_ACTION_TOOL_PROFILE_BASE = {
|
||||
schemaVersion: "ai-workspace.assistant-actions.v1",
|
||||
endpoint: "/api/ai-workspace/assistant/v1/actions",
|
||||
@@ -321,6 +323,59 @@ app.get("/api/ai-workspace/assistant/v1/executors/:executorId/agent/windows.ps1"
|
||||
res.send(source);
|
||||
}));
|
||||
|
||||
app.post("/api/ai-workspace/assistant/v1/executors/:executorId/agent/setup-command", 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).json({ ok: false, error: "ai_workspace_executor_not_found" });
|
||||
return;
|
||||
}
|
||||
if (!cleanPairingCode(executor.pairingCode || "")) {
|
||||
res.status(400).json({ ok: false, error: "pairing_code_required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const installerPort = installerPortFromQuery(req.body?.port || req.query?.port);
|
||||
if (!installerPort) {
|
||||
res.status(400).json({ ok: false, error: "installer_port_invalid" });
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerSettings = await getOwnerSettings(owner);
|
||||
const appMcpServers = installerMcpServersFromSettings(ownerSettings, isPlainObject(req.body) ? req.body : {});
|
||||
const setupCode = await createAiWorkspaceSetupCode(owner, executor, {
|
||||
port: installerPort,
|
||||
appMcpServers,
|
||||
});
|
||||
const command = buildBridgeSetupCommand(setupCode.code);
|
||||
res.status(201).json({
|
||||
ok: true,
|
||||
owner: publicOwner(owner),
|
||||
executorId: executor.id,
|
||||
install: {
|
||||
command,
|
||||
packageName: config.bridgePackageName,
|
||||
gatewayUrl: config.setupGatewayUrl,
|
||||
expiresAt: setupCode.expiresAt,
|
||||
},
|
||||
setupCode: {
|
||||
suffix: setupCode.suffix,
|
||||
expiresAt: setupCode.expiresAt,
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
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) {
|
||||
res.status(400).json({ ok: false, error: "setup_code_required" });
|
||||
return;
|
||||
}
|
||||
const setup = await redeemAiWorkspaceSetupCode(setupCode);
|
||||
res.json({ ok: true, setup });
|
||||
}));
|
||||
|
||||
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;
|
||||
@@ -709,6 +764,160 @@ async function getExecutor(owner, executorId) {
|
||||
return result.rows[0] ? toExecutor(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async function createAiWorkspaceSetupCode(owner, executor, options = {}) {
|
||||
const code = makeAiWorkspaceSetupCode();
|
||||
const expiresAt = new Date(Date.now() + config.setupCodeTtlSeconds * 1000);
|
||||
const metadata = {
|
||||
packageName: config.bridgePackageName,
|
||||
setupGatewayUrl: config.setupGatewayUrl,
|
||||
bridge: bridgeSetupPayload(executor, options),
|
||||
};
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("begin");
|
||||
await client.query(
|
||||
`update ai_workspace_setup_codes
|
||||
set status = 'revoked'
|
||||
where owner_key = $1 and executor_id = $2 and status = 'active'`,
|
||||
[owner.key, executor.id]
|
||||
);
|
||||
await client.query(
|
||||
`insert into ai_workspace_setup_codes (
|
||||
id,
|
||||
owner_key,
|
||||
owner_user_id,
|
||||
owner_email,
|
||||
executor_id,
|
||||
code_hash,
|
||||
code_suffix,
|
||||
expires_at,
|
||||
metadata
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)`,
|
||||
[
|
||||
randomUUID(),
|
||||
owner.key,
|
||||
owner.userId,
|
||||
owner.email,
|
||||
executor.id,
|
||||
hashSetupCode(code),
|
||||
setupCodeSuffix(code),
|
||||
expiresAt,
|
||||
JSON.stringify(metadata),
|
||||
]
|
||||
);
|
||||
await client.query("commit");
|
||||
} catch (error) {
|
||||
await client.query("rollback");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
return {
|
||||
code,
|
||||
suffix: setupCodeSuffix(code),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function redeemAiWorkspaceSetupCode(code) {
|
||||
const client = await pool.connect();
|
||||
let committed = false;
|
||||
try {
|
||||
await client.query("begin");
|
||||
const result = await client.query(
|
||||
`select c.*, e.name as executor_name
|
||||
from ai_workspace_setup_codes c
|
||||
left join ai_workspace_executors e on e.owner_key = c.owner_key and e.id = c.executor_id
|
||||
where c.code_hash = $1
|
||||
for update of c`,
|
||||
[hashSetupCode(code)]
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw badRequest("setup_code_invalid");
|
||||
if (row.status !== "active") throw httpError("setup_code_not_active", 410);
|
||||
if (new Date(row.expires_at).getTime() <= Date.now()) {
|
||||
await client.query(
|
||||
"update ai_workspace_setup_codes set status = 'expired' where id = $1",
|
||||
[row.id]
|
||||
);
|
||||
await client.query("commit");
|
||||
committed = true;
|
||||
throw httpError("setup_code_expired", 410);
|
||||
}
|
||||
|
||||
await client.query(
|
||||
"update ai_workspace_setup_codes set status = 'used', used_at = now() where id = $1",
|
||||
[row.id]
|
||||
);
|
||||
await client.query("commit");
|
||||
committed = true;
|
||||
|
||||
const metadata = isPlainObject(row.metadata) ? row.metadata : {};
|
||||
const bridge = isPlainObject(metadata.bridge) ? metadata.bridge : null;
|
||||
if (!bridge?.pairingCode || !Array.isArray(bridge?.hubUrls) || !bridge.hubUrls.length) {
|
||||
throw httpError("setup_payload_invalid", 500);
|
||||
}
|
||||
return {
|
||||
service: "NDC AI Workspace Bridge",
|
||||
packageName: optionalString(metadata.packageName) || config.bridgePackageName,
|
||||
executor: {
|
||||
id: row.executor_id,
|
||||
name: optionalString(row.executor_name) || optionalString(bridge.machineName) || "Codex worker",
|
||||
},
|
||||
expiresAt: toIso(row.expires_at),
|
||||
bridge,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!committed) {
|
||||
try {
|
||||
await client.query("rollback");
|
||||
} catch {}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
function bridgeSetupPayload(executor, options = {}) {
|
||||
const hubUrls = uniqueStrings([config.hubWebSocketUrl, ...(config.hubFallbackWebSocketUrls || [])]);
|
||||
return {
|
||||
protocol: "ai-workspace-bridge/v1",
|
||||
hubUrl: hubUrls[0] || "",
|
||||
hubUrls,
|
||||
pairingCode: cleanPairingCode(executor.pairingCode || ""),
|
||||
machineName: optionalString(executor.name) || "Codex worker",
|
||||
workspace: optionalString(executor.workspacePath) || "",
|
||||
port: sanitizeInteger(options.port, 8787, 1, 65535),
|
||||
appMcpServers: Array.isArray(options.appMcpServers) ? options.appMcpServers : [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildBridgeSetupCommand(code) {
|
||||
const parts = [
|
||||
"npx",
|
||||
"--yes",
|
||||
config.bridgePackageName,
|
||||
"setup",
|
||||
code,
|
||||
"--gateway",
|
||||
config.setupGatewayUrl,
|
||||
];
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function makeAiWorkspaceSetupCode() {
|
||||
return `${AI_WORKSPACE_SETUP_CODE_PREFIX}_${randomBytes(18).toString("base64url")}`;
|
||||
}
|
||||
|
||||
function hashSetupCode(code) {
|
||||
return createHash("sha256").update(String(code || ""), "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function setupCodeSuffix(code) {
|
||||
return String(code || "").replace(/[^A-Za-z0-9]/g, "").slice(-6).toUpperCase();
|
||||
}
|
||||
|
||||
async function selectExecutor(owner, executorId) {
|
||||
const executor = await getExecutor(owner, executorId);
|
||||
if (!executor) return null;
|
||||
@@ -2769,6 +2978,27 @@ async function migrate() {
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
create table if not exists ai_workspace_setup_codes (
|
||||
id uuid primary key,
|
||||
owner_key text not null,
|
||||
owner_user_id text,
|
||||
owner_email text,
|
||||
executor_id uuid not null references ai_workspace_executors(id) on delete cascade,
|
||||
code_hash text not null unique,
|
||||
code_suffix text not null,
|
||||
status text not null default 'active',
|
||||
expires_at timestamptz not null,
|
||||
used_at timestamptz,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
constraint ai_workspace_setup_codes_owner_check
|
||||
check (owner_user_id is not null or owner_email is not null),
|
||||
constraint ai_workspace_setup_codes_status_check
|
||||
check (status in ('active', 'used', 'expired', 'revoked'))
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query("create index if not exists ai_workspace_executors_owner_idx on ai_workspace_executors(owner_key, updated_at desc)");
|
||||
await pool.query("create index if not exists ai_workspace_executors_pairing_code_idx on ai_workspace_executors(pairing_code) where pairing_code is not null");
|
||||
await pool.query("create index if not exists ai_workspace_threads_owner_surface_idx on ai_workspace_threads(owner_key, origin_surface, updated_at desc)");
|
||||
@@ -2777,6 +3007,8 @@ async function migrate() {
|
||||
await pool.query("create index if not exists ai_workspace_runs_request_idx on ai_workspace_runs(owner_key, request_id)");
|
||||
await pool.query("create index if not exists ai_workspace_run_events_run_idx on ai_workspace_run_events(run_id, occurred_at asc, created_at asc)");
|
||||
await pool.query("create unique index if not exists ai_workspace_run_events_hub_event_idx on ai_workspace_run_events(run_id, hub_event_id) where hub_event_id is not null");
|
||||
await pool.query("create index if not exists ai_workspace_setup_codes_owner_executor_idx on ai_workspace_setup_codes(owner_key, executor_id, created_at desc)");
|
||||
await pool.query("create index if not exists ai_workspace_setup_codes_expires_idx on ai_workspace_setup_codes(status, expires_at)");
|
||||
}
|
||||
|
||||
function sanitizeExecutorCommand(payload, { partial }) {
|
||||
@@ -3412,8 +3644,12 @@ function toIso(value) {
|
||||
}
|
||||
|
||||
function badRequest(message) {
|
||||
return httpError(message, 400);
|
||||
}
|
||||
|
||||
function httpError(message, status = 500) {
|
||||
const error = new Error(message);
|
||||
error.status = 400;
|
||||
error.status = status;
|
||||
return error;
|
||||
}
|
||||
|
||||
@@ -3585,6 +3821,10 @@ function readConfig() {
|
||||
optionalString(process.env.NDC_AI_WORKSPACE_HUB_HTTP_URL) ||
|
||||
optionalString(process.env.AI_WORKSPACE_HUB_HTTP_URL) ||
|
||||
httpUrlFromWebSocketUrl(hubWebSocketUrl);
|
||||
const setupGatewayUrl =
|
||||
optionalString(process.env.AI_WORKSPACE_SETUP_GATEWAY_URL) ||
|
||||
optionalString(process.env.NDC_AI_WORKSPACE_SETUP_GATEWAY_URL) ||
|
||||
httpUrlFromWebSocketUrl(hubWebSocketUrl);
|
||||
const explicitHubAccessToken =
|
||||
optionalString(process.env.AI_WORKSPACE_HUB_TOKEN) ||
|
||||
optionalString(process.env.NDC_AI_WORKSPACE_HUB_TOKEN) ||
|
||||
@@ -3633,6 +3873,18 @@ function readConfig() {
|
||||
databasePoolSize: Number(process.env.AI_WORKSPACE_ASSISTANT_DATABASE_POOL_SIZE || "10"),
|
||||
hubWebSocketUrl,
|
||||
hubInternalHttpUrl: hubInternalHttpUrl.replace(/\/+$/, ""),
|
||||
setupGatewayUrl: setupGatewayUrl.replace(/\/+$/, ""),
|
||||
bridgePackageName:
|
||||
optionalString(process.env.AI_WORKSPACE_BRIDGE_PACKAGE_NAME) ||
|
||||
optionalString(process.env.NDC_AI_WORKSPACE_BRIDGE_PACKAGE_NAME) ||
|
||||
AI_WORKSPACE_BRIDGE_PACKAGE_NAME,
|
||||
setupCodeTtlSeconds: sanitizeInteger(
|
||||
process.env.AI_WORKSPACE_SETUP_CODE_TTL_SECONDS ||
|
||||
process.env.NDC_AI_WORKSPACE_SETUP_CODE_TTL_SECONDS,
|
||||
15 * 60,
|
||||
60,
|
||||
24 * 60 * 60
|
||||
),
|
||||
hubInternalAccessToken:
|
||||
explicitHubAccessToken || (isDeployedPublicHubUrl(hubInternalHttpUrl) ? "" : sharedInternalAccessToken),
|
||||
ontologyLauncherBaseUrl: ontologyLauncherBaseUrl.replace(/\/+$/, ""),
|
||||
|
||||
Reference in New Issue
Block a user