From f35cd34167d3fcba7f396349ef82bcb30fe17ae4 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 24 Jun 2026 22:19:40 +0300 Subject: [PATCH] feat: wire Launcher AI Workspace entitlements --- services/ai-workspace-assistant/README.md | 10 ++ .../src/scripts/smoke-run-profile.mjs | 116 ++++++++++++++++-- .../ai-workspace-assistant/src/server.mjs | 92 ++++++++++++-- 3 files changed, 197 insertions(+), 21 deletions(-) diff --git a/services/ai-workspace-assistant/README.md b/services/ai-workspace-assistant/README.md index 2a3b48d..fc908e4 100644 --- a/services/ai-workspace-assistant/README.md +++ b/services/ai-workspace-assistant/README.md @@ -86,6 +86,16 @@ NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN=... If `NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN` is not set, the service falls back to `NODEDC_INTERNAL_ACCESS_TOKEN`. +Launcher entitlement adapter configuration: + +```text +AI_WORKSPACE_LAUNCHER_ENTITLEMENT_URL=http://launcher:5173/api/ai-workspace/internal/v1/entitlements +AI_WORKSPACE_LAUNCHER_ENTITLEMENT_TOKEN=... +AI_WORKSPACE_LAUNCHER_ENTITLEMENT_REQUIRED=true +``` + +The Launcher adapter is evaluated after app-owned adapters and may return `launcher`, `ops`, and `engine` app grants. A Launcher denial is the top-level service-access decision and clears MCP servers for that app in the run profile; app-owned adapters still own app-local scopes and runtime tokens. + The first HUB action ids exposed to assistants are: - `hub.user.read_admin_summary` 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 ffaa782..e0262a4 100644 --- a/services/ai-workspace-assistant/src/scripts/smoke-run-profile.mjs +++ b/services/ai-workspace-assistant/src/scripts/smoke-run-profile.mjs @@ -5,8 +5,14 @@ import { createHash, randomUUID } from "node:crypto"; const SECRET_TOKEN = "secret-run-token-for-smoke"; const ACCESS_DENIED_TEXT = "Доступ к модулю ограничен, обратитесь к администратору системы."; +const RUN_APP_ID_ALIASES = new Map([ + ["hub", "launcher"], + ["nodedc-hub", "launcher"], + ["nodedc_launcher", "launcher"], + ["nodedc-launcher", "launcher"], +]); const APP_ROUTING_CATALOG = [ - { appId: "launcher", appTitle: "NODE.DC Launcher", surface: "launcher", skillId: "launcher-context", mcpServerNames: [], deniedText: ACCESS_DENIED_TEXT }, + { appId: "launcher", appTitle: "NODE.DC Launcher", surface: "launcher", skillId: "launcher-context", appAliases: ["hub"], actionIdPrefixes: ["hub.", "launcher.", "access."], 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 }, ]; @@ -60,6 +66,37 @@ const assistantActions = { }; const appCatalog = buildRunProfileAppCatalog({ appGrants, mcpServers, assistantActions }); const appAccess = summarizeRunAppAccess(appCatalog); +const launcherAdapter = { id: "launcher", appId: "launcher", title: "NODE.DC Launcher" }; +const launcherGrants = normalizeEntitlementAdapterAppGrants({ + ok: true, + appGrants: { + hub: { + appTitle: "NODE.DC Launcher", + surface: "launcher", + status: "granted", + scopes: ["launcher:access:read", "launcher_admin_scope.any"], + }, + }, +}, launcherAdapter); +const catalogWithLauncherGrant = buildRunProfileAppCatalog({ + appGrants: { ...appGrants, ...launcherGrants }, + mcpServers, + assistantActions, +}); +const deniedByLauncher = mergeRunAppGrants(appGrants, normalizeEntitlementAdapterAppGrants({ + ok: true, + appGrants: { + ops: { + appTitle: "NODE.DC Ops", + surface: "ops", + status: "denied", + allowed: false, + reason: "launcher_service_access_denied", + mcpServers: [], + }, + }, +}, launcherAdapter)); +const deniedMcpServers = runProfileMcpServersFromAppGrants({}, deniedByLauncher); const runProfile = { schemaVersion: "ai-workspace.run-profile.v1", runId: randomUUID(), @@ -138,6 +175,12 @@ assert.equal(runProfile.appCatalog.find((app) => app.appId === "ops")?.status, " 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.equal(launcherGrants.launcher.appId, "launcher"); +assert.equal(launcherGrants.hub, undefined); +assert.deepEqual(catalogWithLauncherGrant.find((app) => app.appId === "launcher")?.actionIds, ["hub.access_request.list_pending", "hub.user.read_admin_summary"]); +assert.equal(deniedByLauncher.ops.status, "denied"); +assert.deepEqual(deniedByLauncher.ops.mcpServers, []); +assert.deepEqual(deniedMcpServers, []); assert.deepEqual(runProfile.appAccess.grantedAppIds, ["ops"]); assert.deepEqual(runProfile.appAccess.deniedAppIds, ["engine", "launcher"]); assert.equal(JSON.stringify(publicProfile).includes(SECRET_TOKEN), false); @@ -150,6 +193,8 @@ console.log(JSON.stringify({ "token_scoped_ops_mcp_in_run_profile", "assistant_action_relay_in_run_profile", "app_routing_catalog_in_run_profile", + "hub_actions_route_to_launcher", + "launcher_denial_removes_ops_mcp", "denied_apps_have_standard_text", "public_run_profile_redacts_mcp_headers", "public_run_profile_redacts_assistant_action_gateway_token", @@ -188,7 +233,7 @@ function normalizeEntitlementAdapterAppGrants(payload, currentAdapter) { function normalizeEntitlementAdapterGrant(value, currentAdapter) { if (!isPlainObject(value)) return null; - const appId = normalizeKey(value.appId || value.app_id || currentAdapter.appId); + const appId = canonicalRunAppId(value.appId || value.app_id || currentAdapter.appId); if (!appId) return null; return { ...value, @@ -200,13 +245,60 @@ function normalizeEntitlementAdapterGrant(value, currentAdapter) { }; } +function canonicalRunAppId(value) { + const appId = normalizeKey(value); + if (!appId) return ""; + return RUN_APP_ID_ALIASES.get(appId) || appId; +} + +function normalizeRunAppGrantsObject(value) { + const out = {}; + if (!isPlainObject(value)) return out; + for (const [key, rawGrant] of Object.entries(value)) { + if (!isPlainObject(rawGrant)) continue; + const appId = canonicalRunAppId(rawGrant.appId || rawGrant.app_id || key); + if (!appId) continue; + const existing = isPlainObject(out[appId]) ? out[appId] : {}; + const grant = { + ...existing, + ...rawGrant, + appId, + }; + grant.mcpServers = isRunAppGrantDenied(grant) + ? [] + : Object.hasOwn(rawGrant, "mcpServers") ? rawGrant.mcpServers : existing.mcpServers; + out[appId] = grant; + } + return out; +} + +function mergeRunAppGrants(base, next) { + const out = normalizeRunAppGrantsObject(base); + for (const [rawAppId, grant] of Object.entries(normalizeRunAppGrantsObject(next))) { + const appId = canonicalRunAppId(grant.appId || rawAppId); + if (!appId) continue; + const existing = isPlainObject(out[appId]) ? out[appId] : {}; + const merged = { + ...existing, + ...grant, + appId, + }; + merged.mcpServers = isRunAppGrantDenied(merged) + ? [] + : Object.hasOwn(grant, "mcpServers") ? grant.mcpServers : existing.mcpServers; + out[appId] = merged; + } + return out; +} + function runProfileMcpServersFromAppGrants(settings, appGrantsInput = {}) { const servers = []; const metadata = isPlainObject(settings?.metadata) ? settings.metadata : {}; collectInstallerMcpServers(servers, metadata.mcpServers, {}); - const grants = isPlainObject(appGrantsInput) ? appGrantsInput : {}; + const grants = normalizeRunAppGrantsObject(appGrantsInput); for (const [appId, grant] of Object.entries(grants)) { if (!isPlainObject(grant)) continue; + if (isRunAppGrantDenied(grant)) continue; collectInstallerMcpServers(servers, grant.mcpServers, { appId: optionalString(grant.appId) || normalizeKey(appId), appTitle: optionalString(grant.appTitle || grant.title), @@ -222,11 +314,11 @@ function runProfileMcpServersFromAppGrants(settings, appGrantsInput = {}) { } function summarizeRunAppGrants(metadata) { - const grants = isPlainObject(metadata?.appGrants) ? metadata.appGrants : {}; + const grants = normalizeRunAppGrantsObject(metadata?.appGrants); const out = {}; for (const [key, value] of Object.entries(grants)) { if (!isPlainObject(value)) continue; - const appId = optionalString(value.appId) || normalizeKey(key); + const appId = canonicalRunAppId(value.appId || key); if (!appId) continue; const denied = isRunAppGrantDenied(value); const mcpServers = Array.isArray(value.mcpServers) @@ -256,7 +348,7 @@ function summarizeRunAppGrants(metadata) { } function buildRunProfileAppCatalog({ appGrants, mcpServers, assistantActions }) { - const grants = isPlainObject(appGrants) ? appGrants : {}; + const grants = normalizeRunAppGrantsObject(appGrants); const allMcpServerNames = uniqueStrings((Array.isArray(mcpServers) ? mcpServers : []) .map((server) => server?.serverName) .filter(Boolean)); @@ -276,7 +368,7 @@ function buildRunProfileAppCatalog({ appGrants, mcpServers, assistantActions }) granted: status === "granted", denied: status !== "granted", deniedText: status !== "granted" ? entry.deniedText : null, - actionIds: actionIds.filter((actionId) => String(actionId || "").startsWith(`${entry.appId}.`)), + actionIds: actionIds.filter((actionId) => actionIdPrefixesForEntry(entry).some((prefix) => String(actionId || "").startsWith(prefix))), mcpServerNames: status === "granted" ? uniqueStrings([ ...entry.mcpServerNames, @@ -288,6 +380,14 @@ function buildRunProfileAppCatalog({ appGrants, mcpServers, assistantActions }) }); } +function actionIdPrefixesForEntry(entry) { + return uniqueStrings([ + ...(Array.isArray(entry.actionIdPrefixes) ? entry.actionIdPrefixes : []), + `${entry.appId}.`, + ...(Array.isArray(entry.appAliases) ? entry.appAliases.map((alias) => `${normalizeKey(alias)}.`) : []), + ].filter(Boolean)); +} + function summarizeRunAppAccess(appCatalog) { const apps = Array.isArray(appCatalog) ? appCatalog : []; return { @@ -306,7 +406,7 @@ function isRunAppGrantDenied(grant) { grant.allowed === false || grant.granted === false || grant.denied === true || - ["denied", "disabled", "blocked", "revoked", "not_granted", "forbidden"].includes(status); + ["denied", "disabled", "blocked", "revoked", "not-granted", "not_granted", "forbidden"].includes(status); } function collectInstallerMcpServers(target, value, defaults = {}) { diff --git a/services/ai-workspace-assistant/src/server.mjs b/services/ai-workspace-assistant/src/server.mjs index 9e03b7a..fde3a09 100644 --- a/services/ai-workspace-assistant/src/server.mjs +++ b/services/ai-workspace-assistant/src/server.mjs @@ -28,6 +28,12 @@ const ASSISTANT_ACTION_TOOL_PROFILE_BASE = { }, }; const ACCESS_DENIED_TEXT = "Доступ к модулю ограничен, обратитесь к администратору системы."; +const RUN_APP_ID_ALIASES = new Map([ + ["hub", "launcher"], + ["nodedc-hub", "launcher"], + ["nodedc_launcher", "launcher"], + ["nodedc-launcher", "launcher"], +]); const APP_ROUTING_CATALOG = [ { appId: "launcher", @@ -43,7 +49,9 @@ const APP_ROUTING_CATALOG = [ "invites", "entitlements", ], - actionNamespaces: ["launcher.*", "access.*"], + appAliases: ["hub"], + actionNamespaces: ["hub.*", "launcher.*", "access.*"], + actionIdPrefixes: ["hub.", "launcher.", "access."], mcpServerNames: [], requiredScopes: ["launcher:access:read"], deniedText: ACCESS_DENIED_TEXT, @@ -2144,7 +2152,7 @@ function delay(ms) { async function resolveRunAppGrants({ owner, context, ownerSettings }) { const metadata = isPlainObject(ownerSettings?.metadata) ? ownerSettings.metadata : {}; const staticAppGrants = isPlainObject(metadata.appGrants) ? metadata.appGrants : {}; - const appGrants = { ...staticAppGrants }; + const appGrants = normalizeRunAppGrantsObject(staticAppGrants); const adapterDiagnostics = []; const adapters = Array.isArray(config.entitlementAdapters) ? config.entitlementAdapters : []; if (!adapters.length) { @@ -2177,8 +2185,10 @@ async function resolveRunAppGrants({ owner, context, ownerSettings }) { continue; } const adapterAppGrants = normalizeEntitlementAdapterAppGrants(result.payload, adapter); - for (const [appId, grant] of Object.entries(adapterAppGrants)) { + for (const [rawAppId, grant] of Object.entries(adapterAppGrants)) { if (!isPlainObject(grant)) continue; + const appId = canonicalRunAppId(grant.appId || rawAppId); + if (!appId) continue; const existing = isPlainObject(appGrants[appId]) ? appGrants[appId] : {}; const merged = { ...existing, @@ -2217,6 +2227,27 @@ async function resolveRunAppGrants({ owner, context, ownerSettings }) { }; } +function normalizeRunAppGrantsObject(value) { + const out = {}; + if (!isPlainObject(value)) return out; + for (const [key, rawGrant] of Object.entries(value)) { + if (!isPlainObject(rawGrant)) continue; + const appId = canonicalRunAppId(rawGrant.appId || rawGrant.app_id || key); + if (!appId) continue; + const existing = isPlainObject(out[appId]) ? out[appId] : {}; + const grant = { + ...existing, + ...rawGrant, + appId, + }; + grant.mcpServers = isRunAppGrantDenied(grant) + ? [] + : Object.hasOwn(rawGrant, "mcpServers") ? rawGrant.mcpServers : existing.mcpServers; + out[appId] = grant; + } + return out; +} + async function fetchRunEntitlementAdapter({ adapter, owner, context, ownerSettings }) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), adapter.timeoutMs); @@ -2288,7 +2319,7 @@ function normalizeEntitlementAdapterAppGrants(payload, adapter) { function normalizeEntitlementAdapterGrant(value, adapter) { if (!isPlainObject(value)) return null; - const appId = normalizeKey(value.appId || value.app_id || adapter.appId); + const appId = canonicalRunAppId(value.appId || value.app_id || adapter.appId); if (!appId) return null; return { ...value, @@ -2300,9 +2331,15 @@ function normalizeEntitlementAdapterGrant(value, adapter) { }; } +function canonicalRunAppId(value) { + const appId = normalizeKey(value); + if (!appId) return ""; + return RUN_APP_ID_ALIASES.get(appId) || appId; +} + function runProfileMcpServersFromSettings(settings) { const metadata = isPlainObject(settings?.metadata) ? settings.metadata : {}; - const appGrants = isPlainObject(metadata.appGrants) ? metadata.appGrants : {}; + const appGrants = normalizeRunAppGrantsObject(metadata.appGrants); return runProfileMcpServersFromAppGrants(settings, appGrants); } @@ -2313,7 +2350,7 @@ function runProfileMcpServersFromAppGrants(settings, appGrantsInput = {}) { if (!hasEntitlementAdapters) { collectInstallerMcpServers(servers, metadata.mcpServers, {}); } - const appGrants = isPlainObject(appGrantsInput) ? appGrantsInput : {}; + const appGrants = normalizeRunAppGrantsObject(appGrantsInput); for (const [appId, grant] of Object.entries(appGrants)) { if (!isPlainObject(grant)) continue; if (isRunAppGrantDenied(grant)) continue; @@ -2332,11 +2369,11 @@ function runProfileMcpServersFromAppGrants(settings, appGrantsInput = {}) { } function summarizeRunAppGrants(metadata) { - const appGrants = isPlainObject(metadata?.appGrants) ? metadata.appGrants : {}; + const appGrants = normalizeRunAppGrantsObject(metadata?.appGrants); const out = {}; for (const [key, value] of Object.entries(appGrants)) { if (!isPlainObject(value)) continue; - const appId = optionalString(value.appId) || normalizeKey(key); + const appId = canonicalRunAppId(value.appId || key); if (!appId) continue; const denied = isRunAppGrantDenied(value); const mcpServers = Array.isArray(value.mcpServers) @@ -2366,9 +2403,9 @@ function summarizeRunAppGrants(metadata) { } function buildRunProfileAppCatalog({ appGrants, mcpServers, assistantActions }) { - const grants = isPlainObject(appGrants) ? appGrants : {}; + const grants = normalizeRunAppGrantsObject(appGrants); const dynamicAppIds = Object.keys(grants) - .map(normalizeKey) + .map(canonicalRunAppId) .filter(Boolean) .filter((appId) => !APP_ROUTING_CATALOG.some((entry) => entry.appId === appId)); const catalog = [ @@ -2380,6 +2417,7 @@ function buildRunProfileAppCatalog({ appGrants, mcpServers, assistantActions }) skillId: `${appId}-context`, whenToUse: [], actionNamespaces: [`${appId}.*`], + actionIdPrefixes: [`${appId}.`], mcpServerNames: [], requiredScopes: [], deniedText: ACCESS_DENIED_TEXT, @@ -2417,6 +2455,11 @@ function buildRunProfileAppCatalog({ appGrants, mcpServers, assistantActions }) ...grantMcpServerNames, ...(mcpByAppId.get(entry.appId) || []), ]); + const actionIdPrefixes = uniqueStrings([ + ...(Array.isArray(entry.actionIdPrefixes) ? entry.actionIdPrefixes : []), + `${entry.appId}.`, + ...(Array.isArray(entry.appAliases) ? entry.appAliases.map((alias) => `${normalizeKey(alias)}.`) : []), + ].filter(Boolean)); return { schemaVersion: "ai-workspace.app-route.v1", appId: entry.appId, @@ -2434,7 +2477,7 @@ function buildRunProfileAppCatalog({ appGrants, mcpServers, assistantActions }) : null, whenToUse: uniqueStrings(entry.whenToUse), actionNamespaces: uniqueStrings(entry.actionNamespaces), - actionIds: actionIds.filter((actionId) => String(actionId || "").startsWith(`${entry.appId}.`)), + actionIds: actionIds.filter((actionId) => actionIdPrefixes.some((prefix) => String(actionId || "").startsWith(prefix))), mcpServerNames: status === "granted" ? advertisedMcpServerNames : [], requiredScopes: uniqueStrings(grant?.requiredScopes || grant?.required_scopes || entry.requiredScopes), scopes: uniqueStrings(Array.isArray(grant?.scopes) ? grant.scopes : []), @@ -4009,12 +4052,35 @@ function parseEntitlementAdapters() { token: process.env.AI_WORKSPACE_ENGINE_ENTITLEMENT_TOKEN || process.env.NDC_AI_WORKSPACE_ENGINE_ENTITLEMENT_TOKEN, required: process.env.AI_WORKSPACE_ENGINE_ENTITLEMENT_REQUIRED || process.env.NDC_AI_WORKSPACE_ENGINE_ENTITLEMENT_REQUIRED, }); + collectEntitlementAdapter(adapters, "launcher", { + url: + process.env.AI_WORKSPACE_LAUNCHER_ENTITLEMENT_URL || + process.env.NDC_AI_WORKSPACE_LAUNCHER_ENTITLEMENT_URL || + process.env.AI_WORKSPACE_HUB_ENTITLEMENT_URL || + process.env.NDC_AI_WORKSPACE_HUB_ENTITLEMENT_URL, + token: + process.env.AI_WORKSPACE_LAUNCHER_ENTITLEMENT_TOKEN || + process.env.NDC_AI_WORKSPACE_LAUNCHER_ENTITLEMENT_TOKEN || + process.env.AI_WORKSPACE_HUB_ENTITLEMENT_TOKEN || + process.env.NDC_AI_WORKSPACE_HUB_ENTITLEMENT_TOKEN, + required: + process.env.AI_WORKSPACE_LAUNCHER_ENTITLEMENT_REQUIRED || + process.env.NDC_AI_WORKSPACE_LAUNCHER_ENTITLEMENT_REQUIRED || + process.env.AI_WORKSPACE_HUB_ENTITLEMENT_REQUIRED || + process.env.NDC_AI_WORKSPACE_HUB_ENTITLEMENT_REQUIRED, + }); const byAppId = new Map(); for (const adapter of adapters) { byAppId.set(adapter.appId, adapter); } - return Array.from(byAppId.values()); + const order = new Map([ + ["ops", 10], + ["engine", 20], + ["launcher", 90], + ]); + return Array.from(byAppId.values()) + .sort((left, right) => (order.get(left.appId) || 50) - (order.get(right.appId) || 50)); } function parseJsonEnv(value) { @@ -4045,7 +4111,7 @@ function collectEntitlementAdapter(target, defaultAppId, value) { function sanitizeEntitlementAdapter(value, defaultAppId = "") { if (!isPlainObject(value)) return null; - const appId = normalizeKey(value.appId || value.app_id || defaultAppId); + const appId = canonicalRunAppId(value.appId || value.app_id || defaultAppId); const url = cleanHttpEndpoint(value.url || value.endpoint); if (!appId || !url) return null; const tokenEnv = optionalString(value.tokenEnv || value.token_env);