diff --git a/services/ai-workspace-assistant/src/scripts/smoke-run-profile.mjs b/services/ai-workspace-assistant/src/scripts/smoke-run-profile.mjs index 8199f1d..ffaa782 100644 --- a/services/ai-workspace-assistant/src/scripts/smoke-run-profile.mjs +++ b/services/ai-workspace-assistant/src/scripts/smoke-run-profile.mjs @@ -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, ""); 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 diff --git a/services/ai-workspace-assistant/src/server.mjs b/services/ai-workspace-assistant/src/server.mjs index edbb854..9e03b7a 100644 --- a/services/ai-workspace-assistant/src/server.mjs +++ b/services/ai-workspace-assistant/src/server.mjs @@ -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 : {};