feat: add AI Workspace app routing catalog

This commit is contained in:
Codex
2026-06-24 21:59:53 +03:00
parent 79928d822e
commit 7003efa75a
2 changed files with 272 additions and 5 deletions
@@ -4,6 +4,12 @@ import assert from "node:assert/strict";
import { createHash, randomUUID } from "node:crypto";
const SECRET_TOKEN = "secret-run-token-for-smoke";
const ACCESS_DENIED_TEXT = "Доступ к модулю ограничен, обратитесь к администратору системы.";
const APP_ROUTING_CATALOG = [
{ appId: "launcher", appTitle: "NODE.DC Launcher", surface: "launcher", skillId: "launcher-context", mcpServerNames: [], deniedText: ACCESS_DENIED_TEXT },
{ appId: "engine", appTitle: "NODE.DC Engine / InJoin", surface: "engine", skillId: "engine-context", mcpServerNames: ["nodedc-engine", "nodedc-agent-core"], deniedText: ACCESS_DENIED_TEXT },
{ appId: "ops", appTitle: "NODE.DC Ops / Tasker", surface: "ops", skillId: "ops-context", mcpServerNames: ["nodedc_ops_agent"], deniedText: ACCESS_DENIED_TEXT },
];
const adapter = {
id: "ops",
@@ -52,6 +58,8 @@ const assistantActions = {
actionIds: ["hub.access_request.list_pending", "hub.user.read_admin_summary"],
phases: ["preview", "execute"],
};
const appCatalog = buildRunProfileAppCatalog({ appGrants, mcpServers, assistantActions });
const appAccess = summarizeRunAppAccess(appCatalog);
const runProfile = {
schemaVersion: "ai-workspace.run-profile.v1",
runId: randomUUID(),
@@ -73,6 +81,8 @@ const runProfile = {
},
enabledToolPacks: ["engine", "ops", "ndc-agent-core"],
appGrants: appGrantSummary,
appCatalog,
appAccess,
toolProfile: {
schemaVersion: "ai-workspace.tool-profile.v1",
enabledToolPacks: ["engine", "ops", "ndc-agent-core"],
@@ -91,6 +101,9 @@ const runProfile = {
adapters: [{ appId: "ops", status: "ok", required: true }],
},
mcpServerNames: mcpServers.map((server) => server.serverName),
appCatalogIds: appCatalog.map((app) => app.appId).sort(),
grantedAppIds: appAccess.grantedAppIds,
deniedAppIds: appAccess.deniedAppIds,
},
};
runProfile.diagnostics.profileHash = runProfileHash(runProfile);
@@ -102,6 +115,8 @@ assert.equal(appGrants.ops.appId, "ops");
assert.deepEqual(appGrantSummary.ops.scopes, ["workspace:read", "project:read", "issue:read"]);
assert.equal(appGrantSummary.ops.hasMcpServers, true);
assert.deepEqual(appGrantSummary.ops.mcpServerNames, ["nodedc_ops_agent"]);
assert.equal(appGrantSummary.ops.status, "granted");
assert.equal(appGrantSummary.ops.denied, false);
assert.equal(mcpServers.length, 1);
assert.equal(mcpServers[0].appId, "ops");
@@ -119,6 +134,12 @@ assert.equal(publicProfile.toolProfile.assistantActions.endpoint, "/api/ai-works
assert.equal(publicProfile.toolProfile.assistantActions.gatewayUrl, "https://ai-hub.nodedc.ru/api/ai-workspace/hub/v1/assistant-relays/local-dev/actions");
assert.equal(publicProfile.toolProfile.assistantActions.gatewayToken, "<redacted>");
assert.deepEqual(publicProfile.toolProfile.assistantActions.actionIds, ["hub.access_request.list_pending", "hub.user.read_admin_summary"]);
assert.equal(runProfile.appCatalog.find((app) => app.appId === "ops")?.status, "granted");
assert.deepEqual(runProfile.appCatalog.find((app) => app.appId === "ops")?.mcpServerNames, ["nodedc_ops_agent"]);
assert.equal(runProfile.appCatalog.find((app) => app.appId === "launcher")?.status, "not-granted");
assert.equal(runProfile.appCatalog.find((app) => app.appId === "launcher")?.deniedText, ACCESS_DENIED_TEXT);
assert.deepEqual(runProfile.appAccess.grantedAppIds, ["ops"]);
assert.deepEqual(runProfile.appAccess.deniedAppIds, ["engine", "launcher"]);
assert.equal(JSON.stringify(publicProfile).includes(SECRET_TOKEN), false);
assert.match(runProfile.diagnostics.profileHash, /^[a-f0-9]{16}$/);
@@ -128,6 +149,8 @@ console.log(JSON.stringify({
"adapter_grant_normalized",
"token_scoped_ops_mcp_in_run_profile",
"assistant_action_relay_in_run_profile",
"app_routing_catalog_in_run_profile",
"denied_apps_have_standard_text",
"public_run_profile_redacts_mcp_headers",
"public_run_profile_redacts_assistant_action_gateway_token",
"stable_public_profile_hash",
@@ -205,6 +228,7 @@ function summarizeRunAppGrants(metadata) {
if (!isPlainObject(value)) continue;
const appId = optionalString(value.appId) || normalizeKey(key);
if (!appId) continue;
const denied = isRunAppGrantDenied(value);
const mcpServers = Array.isArray(value.mcpServers)
? value.mcpServers
: isPlainObject(value.mcpServers)
@@ -214,10 +238,15 @@ function summarizeRunAppGrants(metadata) {
appId,
appTitle: optionalString(value.appTitle || value.title),
surface: optionalString(value.surface) || appId,
status: denied ? "denied" : "granted",
granted: !denied,
denied,
deniedReason: denied ? optionalString(value.reason || value.deniedReason || value.denied_reason || value.status) : null,
deniedText: denied ? optionalString(value.deniedText || value.denied_text) || ACCESS_DENIED_TEXT : null,
updatedAt: optionalString(value.updatedAt || value.updated_at),
context: redactForPublicDiagnostics(isPlainObject(value.context) ? value.context : {}),
scopes: uniqueStrings(Array.isArray(value.scopes) ? value.scopes : []),
hasMcpServers: mcpServers.length > 0,
hasMcpServers: !denied && mcpServers.length > 0,
mcpServerNames: mcpServers
.map((server) => safeMcpServerName(server?.serverName || server?.server_name || server?.name))
.filter(Boolean),
@@ -226,6 +255,60 @@ function summarizeRunAppGrants(metadata) {
return out;
}
function buildRunProfileAppCatalog({ appGrants, mcpServers, assistantActions }) {
const grants = isPlainObject(appGrants) ? appGrants : {};
const allMcpServerNames = uniqueStrings((Array.isArray(mcpServers) ? mcpServers : [])
.map((server) => server?.serverName)
.filter(Boolean));
const actionIds = Array.isArray(assistantActions?.actionIds) ? assistantActions.actionIds : [];
return APP_ROUTING_CATALOG.map((entry) => {
const grant = isPlainObject(grants[entry.appId]) ? grants[entry.appId] : null;
const grantedByLegacyMcp = !grant && entry.mcpServerNames.some((name) => allMcpServerNames.includes(name));
const denied = grant ? isRunAppGrantDenied(grant) : !grantedByLegacyMcp;
const status = grant ? (denied ? "denied" : "granted") : grantedByLegacyMcp ? "granted" : "not-granted";
return {
schemaVersion: "ai-workspace.app-route.v1",
appId: entry.appId,
appTitle: entry.appTitle,
surface: entry.surface,
skillId: entry.skillId,
status,
granted: status === "granted",
denied: status !== "granted",
deniedText: status !== "granted" ? entry.deniedText : null,
actionIds: actionIds.filter((actionId) => String(actionId || "").startsWith(`${entry.appId}.`)),
mcpServerNames: status === "granted"
? uniqueStrings([
...entry.mcpServerNames,
...((Array.isArray(grant?.mcpServers) ? grant.mcpServers : []).map((server) => safeMcpServerName(server?.serverName || server?.server_name || server?.name))),
])
: [],
scopes: uniqueStrings(Array.isArray(grant?.scopes) ? grant.scopes : []),
};
});
}
function summarizeRunAppAccess(appCatalog) {
const apps = Array.isArray(appCatalog) ? appCatalog : [];
return {
schemaVersion: "ai-workspace.app-access.v1",
grantedAppIds: apps.filter((app) => app?.granted === true).map((app) => app.appId).sort(),
deniedAppIds: apps.filter((app) => app?.granted !== true).map((app) => app.appId).sort(),
availableSkillIds: apps.filter((app) => app?.granted === true).map((app) => app.skillId).filter(Boolean).sort(),
deniedText: ACCESS_DENIED_TEXT,
};
}
function isRunAppGrantDenied(grant) {
if (!isPlainObject(grant)) return false;
const status = normalizeKey(grant.status || grant.state || grant.accessStatus || grant.access_status);
return grant.enabled === false ||
grant.allowed === false ||
grant.granted === false ||
grant.denied === true ||
["denied", "disabled", "blocked", "revoked", "not_granted", "forbidden"].includes(status);
}
function collectInstallerMcpServers(target, value, defaults = {}) {
const items = Array.isArray(value)
? value