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

View File

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

View File

@ -27,6 +27,65 @@ const ASSISTANT_ACTION_TOOL_PROFILE_BASE = {
destructive: "forbidden",
},
};
const ACCESS_DENIED_TEXT = "Доступ к модулю ограничен, обратитесь к администратору системы.";
const APP_ROUTING_CATALOG = [
{
appId: "launcher",
appTitle: "NODE.DC Launcher",
surface: "launcher",
skillId: "launcher-context",
whenToUse: [
"users",
"roles",
"admin scopes",
"application access",
"company/workspace contours",
"invites",
"entitlements",
],
actionNamespaces: ["launcher.*", "access.*"],
mcpServerNames: [],
requiredScopes: ["launcher:access:read"],
deniedText: ACCESS_DENIED_TEXT,
},
{
appId: "engine",
appTitle: "NODE.DC Engine / InJoin",
surface: "engine",
skillId: "engine-context",
whenToUse: [
"workflow graph",
"code",
"runtime",
"agent node",
"NDC Agent Core",
"engineering automation",
],
actionNamespaces: ["engine.*", "ndc-agent-core.*"],
mcpServerNames: ["nodedc-engine", "nodedc-agent-core"],
requiredScopes: ["engine:workspace:read"],
deniedText: ACCESS_DENIED_TEXT,
},
{
appId: "ops",
appTitle: "NODE.DC Ops / Tasker",
surface: "ops",
skillId: "ops-context",
whenToUse: [
"projects",
"cards",
"comments",
"structured blocks",
"checkers",
"labels",
"operational reports",
],
actionNamespaces: ["ops.*", "tasker_*"],
mcpServerNames: ["nodedc-ops-agent", "nodedc_ops_agent"],
requiredScopes: ["ops:project:read"],
deniedText: ACCESS_DENIED_TEXT,
},
];
const ASSISTANT_ACTION_CACHE_TTL_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_ACTION_CACHE_TTL_MS || 30_000);
const ASSISTANT_ACTION_RELAY_POLL_TIMEOUT_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_POLL_TIMEOUT_MS || 25_000);
const ASSISTANT_ACTION_RELAY_POLL_IDLE_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_POLL_IDLE_MS || 500);
@ -1868,6 +1927,12 @@ async function buildRunProfile({ owner, thread, executor, ownerSettings, bridgeP
const mcpServers = runProfileMcpServersFromAppGrants(ownerSettings, grantResolution.appGrants);
const mcpServerNames = mcpServers.map((server) => server.serverName).filter(Boolean);
const assistantActions = await assistantActionToolProfileForRun();
const appCatalog = buildRunProfileAppCatalog({
appGrants: grantResolution.appGrants,
mcpServers,
assistantActions,
});
const appAccess = summarizeRunAppAccess(appCatalog);
const requiredMcpServerNames = mcpServers
.filter((server) => server.required === true)
.map((server) => server.serverName)
@ -1882,6 +1947,9 @@ async function buildRunProfile({ owner, thread, executor, ownerSettings, bridgeP
targetSurfaces: Object.keys(targetContexts).map(normalizeKey).filter(Boolean).sort(),
enabledToolPacks,
appGrantIds: Object.keys(appGrants).sort(),
appCatalogIds: appCatalog.map((app) => app.appId).sort(),
grantedAppIds: appAccess.grantedAppIds,
deniedAppIds: appAccess.deniedAppIds,
entitlementAdapters: grantResolution.diagnostics,
mcpServerNames,
requiredMcpServerNames,
@ -1907,6 +1975,8 @@ async function buildRunProfile({ owner, thread, executor, ownerSettings, bridgeP
targetContexts: redactForPublicDiagnostics(targetContexts),
enabledToolPacks,
appGrants,
appCatalog,
appAccess,
toolProfile: {
schemaVersion: "ai-workspace.tool-profile.v1",
enabledToolPacks,
@ -2110,12 +2180,15 @@ async function resolveRunAppGrants({ owner, context, ownerSettings }) {
for (const [appId, grant] of Object.entries(adapterAppGrants)) {
if (!isPlainObject(grant)) continue;
const existing = isPlainObject(appGrants[appId]) ? appGrants[appId] : {};
appGrants[appId] = {
const merged = {
...existing,
...grant,
appId,
mcpServers: Object.hasOwn(grant, "mcpServers") ? grant.mcpServers : existing.mcpServers,
};
merged.mcpServers = isRunAppGrantDenied(merged)
? []
: Object.hasOwn(grant, "mcpServers") ? grant.mcpServers : existing.mcpServers;
appGrants[appId] = merged;
}
adapterDiagnostics.push({
appId: adapter.appId,
@ -2236,10 +2309,14 @@ function runProfileMcpServersFromSettings(settings) {
function runProfileMcpServersFromAppGrants(settings, appGrantsInput = {}) {
const servers = [];
const metadata = isPlainObject(settings?.metadata) ? settings.metadata : {};
collectInstallerMcpServers(servers, metadata.mcpServers, {});
const hasEntitlementAdapters = Array.isArray(config.entitlementAdapters) && config.entitlementAdapters.length > 0;
if (!hasEntitlementAdapters) {
collectInstallerMcpServers(servers, metadata.mcpServers, {});
}
const appGrants = isPlainObject(appGrantsInput) ? appGrantsInput : {};
for (const [appId, grant] of Object.entries(appGrants)) {
if (!isPlainObject(grant)) continue;
if (isRunAppGrantDenied(grant)) continue;
collectInstallerMcpServers(servers, grant.mcpServers, {
appId: optionalString(grant.appId) || normalizeKey(appId),
appTitle: optionalString(grant.appTitle || grant.title),
@ -2261,6 +2338,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)
@ -2270,10 +2348,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),
@ -2282,6 +2365,104 @@ function summarizeRunAppGrants(metadata) {
return out;
}
function buildRunProfileAppCatalog({ appGrants, mcpServers, assistantActions }) {
const grants = isPlainObject(appGrants) ? appGrants : {};
const dynamicAppIds = Object.keys(grants)
.map(normalizeKey)
.filter(Boolean)
.filter((appId) => !APP_ROUTING_CATALOG.some((entry) => entry.appId === appId));
const catalog = [
...APP_ROUTING_CATALOG,
...dynamicAppIds.map((appId) => ({
appId,
appTitle: appId,
surface: appId,
skillId: `${appId}-context`,
whenToUse: [],
actionNamespaces: [`${appId}.*`],
mcpServerNames: [],
requiredScopes: [],
deniedText: ACCESS_DENIED_TEXT,
})),
];
const mcpByAppId = new Map();
for (const server of Array.isArray(mcpServers) ? mcpServers : []) {
const appId = normalizeKey(server?.appId);
if (!appId) continue;
const list = mcpByAppId.get(appId) || [];
if (server?.serverName && !list.includes(server.serverName)) list.push(server.serverName);
mcpByAppId.set(appId, list);
}
const actionIds = Array.isArray(assistantActions?.actionIds) ? assistantActions.actionIds : [];
const allMcpServerNames = uniqueStrings((Array.isArray(mcpServers) ? mcpServers : [])
.map((server) => server?.serverName)
.filter(Boolean));
return 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";
const grantMcpServers = grant
? Array.isArray(grant.mcpServers)
? grant.mcpServers
: isPlainObject(grant.mcpServers)
? Object.values(grant.mcpServers)
: []
: [];
const grantMcpServerNames = grantMcpServers
.map((server) => safeMcpServerName(server?.serverName || server?.server_name || server?.name))
.filter(Boolean);
const advertisedMcpServerNames = uniqueStrings([
...entry.mcpServerNames,
...grantMcpServerNames,
...(mcpByAppId.get(entry.appId) || []),
]);
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",
deniedReason: status !== "granted"
? optionalString(grant?.reason || grant?.deniedReason || grant?.denied_reason || grant?.status) || status
: null,
deniedText: status !== "granted"
? optionalString(grant?.deniedText || grant?.denied_text) || entry.deniedText || ACCESS_DENIED_TEXT
: null,
whenToUse: uniqueStrings(entry.whenToUse),
actionNamespaces: uniqueStrings(entry.actionNamespaces),
actionIds: actionIds.filter((actionId) => String(actionId || "").startsWith(`${entry.appId}.`)),
mcpServerNames: status === "granted" ? advertisedMcpServerNames : [],
requiredScopes: uniqueStrings(grant?.requiredScopes || grant?.required_scopes || entry.requiredScopes),
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", "not_granted", "forbidden"].includes(status);
}
function buildRunProfilePolicyPrompt({ context, diagnostics, assistantActions }) {
const lines = [
"AI Workspace dynamic run profile:",
@ -2291,12 +2472,15 @@ function buildRunProfilePolicyPrompt({ context, diagnostics, assistantActions })
`- context ready: ${diagnostics.contextReady ? "yes" : "no"}`,
`- entitlement source: ${diagnostics.entitlementAdapters?.source || "settings"}`,
`- enabled tool packs: ${diagnostics.enabledToolPacks.length ? diagnostics.enabledToolPacks.join(", ") : "none"}`,
`- app routes granted: ${diagnostics.grantedAppIds.length ? diagnostics.grantedAppIds.join(", ") : "none"}`,
`- app routes denied/not granted: ${diagnostics.deniedAppIds.length ? diagnostics.deniedAppIds.join(", ") : "none"}`,
`- MCP servers available in this run: ${diagnostics.mcpServerNames.length ? diagnostics.mcpServerNames.join(", ") : "none"}`,
`- assistant action ids available: ${Array.isArray(assistantActions?.actionIds) ? assistantActions.actionIds.join(", ") : "none"}`,
"- Interpret the user's natural-language request first; call assistant actions only after selecting a structured action id.",
"- Read assistant actions may execute after structured action selection. Privileged/write assistant actions require preview, explicit user confirmation, then execute.",
"- Ops card actions advertised in this run are valid assistant actions: use ops.card.list_recent for reading cards, ops.card.create for creating cards, and ops.card.add_comment for comments instead of refusing because direct Ops MCP tools are absent.",
"- Destructive assistant actions are forbidden; offer safe alternatives such as block/disable instead of delete.",
`- If the user requests a denied or not-granted app capability, do not bypass it. Say exactly: ${ACCESS_DENIED_TEXT}`,
"- MCP tokens and headers are runtime secrets and must never be printed in public answers.",
];
const opsContext = isPlainObject(context?.contexts?.ops) ? context.contexts.ops : {};