feat(ontology): add core service catalog
This commit is contained in:
@@ -0,0 +1,994 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { buildHubLauncherAdminPlan } from './adapters/hub-launcher-admin.mjs'
|
||||
import { buildOpsProductPlan } from './adapters/ops-product-api.mjs'
|
||||
import { resolveAssistantAction } from './assistant-action-resolver.mjs'
|
||||
import { validateCatalog } from './validate.mjs'
|
||||
|
||||
const WRITE_METHODS = new Set(['PATCH', 'POST', 'PUT'])
|
||||
const HUB_LAUNCHER_ADMIN_ADAPTER_ID = 'adapter.hub.launcher_admin_api'
|
||||
const OPS_PRODUCT_API_ADAPTER_ID = 'adapter.ops.product_api'
|
||||
const ASSISTANT_GATEWAY_NAME = 'ontology-core'
|
||||
const CONFIRMATION_ENVELOPE_VERSION = 'assistant-write-confirmation-v1'
|
||||
const ALLOWED_ROUTES = [
|
||||
{ method: 'GET', pattern: /^\/api\/admin\/control-plane$/ },
|
||||
{ method: 'PATCH', pattern: /^\/api\/admin\/users\/[^/?#]+\/profile$/ },
|
||||
{ method: 'PATCH', pattern: /^\/api\/admin\/memberships\/[^/?#]+$/ },
|
||||
]
|
||||
const OPS_ALLOWED_ROUTES = [
|
||||
{ method: 'GET', pattern: /^\/api\/v1\/tools\/issues$/ },
|
||||
{ method: 'POST', pattern: /^\/api\/v1\/tools\/issues$/ },
|
||||
{ method: 'POST', pattern: /^\/api\/v1\/tools\/issues\/[^/?#]+\/comments$/ },
|
||||
]
|
||||
|
||||
function headerValue(headers, key) {
|
||||
const wanted = key.toLowerCase()
|
||||
for (const [candidateKey, value] of Object.entries(headers || {})) {
|
||||
if (candidateKey.toLowerCase() === wanted) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeMethod(method) {
|
||||
return String(method || '').trim().toUpperCase()
|
||||
}
|
||||
|
||||
function isAllowedRoute(method, path) {
|
||||
return ALLOWED_ROUTES.some((route) => route.method === method && route.pattern.test(path))
|
||||
}
|
||||
|
||||
function isAllowedOpsRoute(method, path) {
|
||||
return OPS_ALLOWED_ROUTES.some((route) => route.method === method && route.pattern.test(path))
|
||||
}
|
||||
|
||||
function appendQuery(url, query = {}) {
|
||||
for (const [key, value] of Object.entries(query || {})) {
|
||||
if (value === null || value === undefined || value === '') continue
|
||||
url.searchParams.set(key, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
function buildRequestUrl(baseUrl, request) {
|
||||
if (!baseUrl) throw new Error('baseUrl is required for network execution')
|
||||
const url = new URL(request.path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`)
|
||||
appendQuery(url, request.query)
|
||||
return url
|
||||
}
|
||||
|
||||
function readResponseHeader(headers, key) {
|
||||
if (!headers) return ''
|
||||
if (typeof headers.get === 'function') return headers.get(key) || ''
|
||||
return headerValue(headers, key) || ''
|
||||
}
|
||||
|
||||
function normalizeHeaderString(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function stableJson(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableJson(item)).join(',')}]`
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return `{${Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
|
||||
.join(',')}}`
|
||||
}
|
||||
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
function confirmationDigest(payload) {
|
||||
return createHash('sha256').update(stableJson(payload)).digest('hex').slice(0, 24)
|
||||
}
|
||||
|
||||
function buildWriteConfirmationEnvelope(plan) {
|
||||
const request = plan?.request || {}
|
||||
const method = normalizeMethod(request.method)
|
||||
if (!WRITE_METHODS.has(method)) return null
|
||||
|
||||
const action = plan?.actionDecision?.action || {}
|
||||
const actor = plan?.gatewayActor || {}
|
||||
const payload = {
|
||||
version: CONFIRMATION_ENVELOPE_VERSION,
|
||||
actionId: action.id || null,
|
||||
app: action.app || null,
|
||||
adapterId: plan?.adapterId || null,
|
||||
actor: {
|
||||
userId: actor.userId || null,
|
||||
email: actor.email || null,
|
||||
subject: actor.subject || null,
|
||||
},
|
||||
request: {
|
||||
method,
|
||||
path: String(request.path || ''),
|
||||
body: request.body ?? null,
|
||||
idempotencyKey: headerValue(request.headers, 'Idempotency-Key') || null,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
token: confirmationDigest(payload),
|
||||
riskLevel: action.riskLevel || null,
|
||||
confirmationMode: action.confirmationMode || null,
|
||||
expectedEffect: action.summary || request.summary || null,
|
||||
summary: request.summary || null,
|
||||
}
|
||||
}
|
||||
|
||||
function readConfirmationToken(input = {}, options = {}) {
|
||||
return normalizeHeaderString(
|
||||
options.confirmationToken ||
|
||||
input.confirmationToken ||
|
||||
input.confirmation?.token ||
|
||||
input.confirmationEnvelope?.token,
|
||||
)
|
||||
}
|
||||
|
||||
function verifyWriteConfirmationEnvelope(input, options, envelope) {
|
||||
if (!envelope) return { ok: true }
|
||||
|
||||
const providedToken = readConfirmationToken(input, options)
|
||||
if (!providedToken) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'write_confirmation_envelope_missing',
|
||||
}
|
||||
}
|
||||
|
||||
if (providedToken !== envelope.token) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'write_confirmation_envelope_mismatch',
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function buildBearerHeader(token) {
|
||||
const value = normalizeHeaderString(token)
|
||||
if (!value) return ''
|
||||
return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`
|
||||
}
|
||||
|
||||
function buildHubLauncherGatewayHeaders(plan, options = {}) {
|
||||
if (plan?.adapterId !== HUB_LAUNCHER_ADMIN_ADAPTER_ID) {
|
||||
return { ok: true, headers: {} }
|
||||
}
|
||||
|
||||
const token = normalizeHeaderString(options.launcherInternalToken || options.internalAccessToken)
|
||||
if (!token) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'launcher_internal_token_missing',
|
||||
}
|
||||
}
|
||||
|
||||
const actor = options.gatewayActor || plan.gatewayActor || {}
|
||||
const actorUserId = normalizeHeaderString(actor.userId)
|
||||
const actorEmail = normalizeHeaderString(actor.email)
|
||||
const actorSubject = normalizeHeaderString(actor.subject)
|
||||
|
||||
if (!actorUserId && !actorEmail && !actorSubject) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'assistant_gateway_actor_missing',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
headers: {
|
||||
Authorization: buildBearerHeader(token),
|
||||
'X-NODEDC-Assistant-Gateway': ASSISTANT_GATEWAY_NAME,
|
||||
...(actorUserId ? { 'X-NODEDC-Assistant-Actor-User-Id': actorUserId } : {}),
|
||||
...(actorEmail ? { 'X-NODEDC-Assistant-Actor-Email': actorEmail } : {}),
|
||||
...(actorSubject ? { 'X-NODEDC-Assistant-Actor-Subject': actorSubject } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function endpointOrigin(value) {
|
||||
try {
|
||||
return new URL(value).origin
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function firstArray(...values) {
|
||||
for (const value of values) {
|
||||
if (Array.isArray(value)) return value
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function entitlementOpsGrant(payload) {
|
||||
const grants = payload?.appGrants || payload?.grant || payload?.appGrant || payload?.entitlement || payload?.entitlements
|
||||
if (grants?.ops && typeof grants.ops === 'object') return grants.ops
|
||||
if (payload?.ops && typeof payload.ops === 'object') return payload.ops
|
||||
return null
|
||||
}
|
||||
|
||||
function authorizationFromOpsGrant(grant) {
|
||||
const servers = firstArray(grant?.mcpServers, grant?.mcp_servers)
|
||||
for (const server of servers) {
|
||||
const headers = server?.httpHeaders || server?.headers || {}
|
||||
const authorization = normalizeHeaderString(headerValue(headers, 'Authorization'))
|
||||
if (authorization) return authorization
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function omitEmptyObject(value = {}) {
|
||||
const out = {}
|
||||
for (const [key, raw] of Object.entries(value || {})) {
|
||||
const text = normalizeHeaderString(raw)
|
||||
if (text) out[key] = text
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function resolveOpsAuthorization(plan, options = {}) {
|
||||
const explicit = buildBearerHeader(options.opsRunToken || options.opsGatewayToken)
|
||||
if (explicit) return { ok: true, authorization: explicit }
|
||||
|
||||
const entitlementUrl = normalizeHeaderString(options.opsEntitlementUrl)
|
||||
const entitlementAuthorization = normalizeHeaderString(
|
||||
options.opsEntitlementAuthorization || buildBearerHeader(options.opsEntitlementToken),
|
||||
)
|
||||
if (!entitlementUrl || !entitlementAuthorization) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'ops_entitlement_not_configured',
|
||||
}
|
||||
}
|
||||
|
||||
const actor = options.gatewayActor || plan.gatewayActor || {}
|
||||
const opsContext = plan.opsContext || {}
|
||||
const fetchImpl = options.fetchImpl || globalThis.fetch
|
||||
if (typeof fetchImpl !== 'function') {
|
||||
throw new Error('No fetch implementation available')
|
||||
}
|
||||
|
||||
const owner = omitEmptyObject({
|
||||
userId: actor.userId,
|
||||
email: actor.email,
|
||||
ownerKey: actor.subject,
|
||||
})
|
||||
const ops = omitEmptyObject({
|
||||
workspaceSlug: opsContext.workspaceSlug,
|
||||
projectId: opsContext.projectId,
|
||||
})
|
||||
|
||||
const response = await fetchImpl(entitlementUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: entitlementAuthorization,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
schemaVersion: 'ai-workspace.entitlement-request.v1',
|
||||
appId: 'ops',
|
||||
owner,
|
||||
runContext: {
|
||||
...(Object.keys(ops).length ? { ops } : {}),
|
||||
...omitEmptyObject({
|
||||
opsWorkspaceSlug: opsContext.workspaceSlug,
|
||||
opsProjectId: opsContext.projectId,
|
||||
}),
|
||||
},
|
||||
activeContext: {},
|
||||
requestedAt: new Date().toISOString(),
|
||||
}),
|
||||
})
|
||||
const payload = await readResponsePayload(response)
|
||||
if (!response.ok || payload?.ok === false) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: payload?.error || payload?.message || `ops_entitlement_http_${response.status}`,
|
||||
}
|
||||
}
|
||||
|
||||
const grant = entitlementOpsGrant(payload)
|
||||
const authorization = authorizationFromOpsGrant(grant)
|
||||
if (!authorization) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: grant?.status || 'ops_entitlement_token_missing',
|
||||
grantStatus: grant?.status || null,
|
||||
grantContext: grant?.context || null,
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, authorization }
|
||||
}
|
||||
|
||||
async function buildGatewayHeaders(plan, options = {}) {
|
||||
if (plan?.adapterId === HUB_LAUNCHER_ADMIN_ADAPTER_ID) {
|
||||
return buildHubLauncherGatewayHeaders(plan, options)
|
||||
}
|
||||
|
||||
if (plan?.adapterId === OPS_PRODUCT_API_ADAPTER_ID) {
|
||||
const resolved = await resolveOpsAuthorization(plan, options)
|
||||
if (!resolved.ok) return resolved
|
||||
return {
|
||||
ok: true,
|
||||
headers: {
|
||||
Authorization: resolved.authorization,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, headers: {} }
|
||||
}
|
||||
|
||||
async function readResponsePayload(response) {
|
||||
const raw = typeof response.text === 'function' ? await response.text() : ''
|
||||
const contentType = readResponseHeader(response.headers, 'content-type')
|
||||
if (!raw) return null
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function planActionId(plan) {
|
||||
return String(plan?.actionDecision?.action?.id || '').trim()
|
||||
}
|
||||
|
||||
function dataArray(payload, key) {
|
||||
const value = payload?.data?.[key]
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
function isPendingLikeStatus(value) {
|
||||
const status = String(value || '').trim().toLowerCase()
|
||||
return ['new', 'pending', 'requested', 'open', 'waiting', 'submitted'].includes(status)
|
||||
}
|
||||
|
||||
function compactAccessRequest(item = {}) {
|
||||
return {
|
||||
id: item.id || null,
|
||||
email: item.email || null,
|
||||
name: [item.lastName, item.firstName, item.middleName].filter(Boolean).join(' ').trim() || null,
|
||||
company: item.company || null,
|
||||
phone: item.phone || null,
|
||||
status: item.status || null,
|
||||
targetClientId: item.targetClientId || null,
|
||||
role: item.role || null,
|
||||
createdAt: item.createdAt || null,
|
||||
updatedAt: item.updatedAt || null,
|
||||
}
|
||||
}
|
||||
|
||||
function compactInvite(item = {}) {
|
||||
return {
|
||||
id: item.id || null,
|
||||
email: item.email || item.inviteeEmail || null,
|
||||
status: item.status || null,
|
||||
clientId: item.clientId || item.targetClientId || null,
|
||||
role: item.role || null,
|
||||
createdAt: item.createdAt || null,
|
||||
updatedAt: item.updatedAt || null,
|
||||
}
|
||||
}
|
||||
|
||||
function compactUser(item = {}) {
|
||||
return {
|
||||
id: item.id || null,
|
||||
name: item.name || null,
|
||||
email: item.email || null,
|
||||
globalStatus: item.globalStatus || null,
|
||||
}
|
||||
}
|
||||
|
||||
function compactMembership(item = {}) {
|
||||
return {
|
||||
id: item.id || null,
|
||||
clientId: item.clientId || null,
|
||||
userId: item.userId || null,
|
||||
role: item.role || null,
|
||||
status: item.status || null,
|
||||
coreAssistantRole: item.coreAssistantRole || null,
|
||||
}
|
||||
}
|
||||
|
||||
function firstIssueArray(payload) {
|
||||
return firstArray(
|
||||
payload?.issues,
|
||||
payload?.items,
|
||||
payload?.results,
|
||||
payload?.data?.issues,
|
||||
payload?.data?.items,
|
||||
payload?.data?.results,
|
||||
Array.isArray(payload?.data) ? payload.data : null,
|
||||
)
|
||||
}
|
||||
|
||||
function firstObject(...values) {
|
||||
for (const value of values) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function issueTimestamp(item = {}) {
|
||||
const value = item.updatedAt || item.updated_at || item.createdAt || item.created_at || item.created || item.updated
|
||||
const time = Date.parse(value)
|
||||
return Number.isFinite(time) ? time : 0
|
||||
}
|
||||
|
||||
function compactIssue(item = {}) {
|
||||
const state = item.state && typeof item.state === 'object' ? item.state : null
|
||||
const project = item.project && typeof item.project === 'object' ? item.project : null
|
||||
return {
|
||||
id: item.id || item.issue_id || null,
|
||||
identifier: item.identifier || item.sequence_id || item.issueIdentifier || item.issue_identifier || null,
|
||||
title: item.title || item.name || item.subject || null,
|
||||
description: item.description || null,
|
||||
state: state?.name || item.stateName || item.state_name || item.state || null,
|
||||
priority: item.priority || null,
|
||||
projectId: item.project_id || item.projectId || project?.id || null,
|
||||
projectName: item.project_name || item.projectName || project?.name || null,
|
||||
createdAt: item.createdAt || item.created_at || item.created || null,
|
||||
updatedAt: item.updatedAt || item.updated_at || item.updated || null,
|
||||
url: item.url || item.web_url || item.webUrl || null,
|
||||
}
|
||||
}
|
||||
|
||||
function compactComment(item = {}) {
|
||||
return {
|
||||
id: item.id || item.comment_id || item.commentId || null,
|
||||
issueId: item.issue_id || item.issueId || item.issue?.id || null,
|
||||
body: item.body || item.comment || item.text || null,
|
||||
createdAt: item.createdAt || item.created_at || item.created || null,
|
||||
updatedAt: item.updatedAt || item.updated_at || item.updated || null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOpsPayload(plan, payload) {
|
||||
if (!payload || typeof payload !== 'object') return payload
|
||||
const actionId = planActionId(plan)
|
||||
if (actionId === 'ops.card.create') {
|
||||
const issue = firstObject(
|
||||
payload.issue,
|
||||
payload.card,
|
||||
payload.data?.issue,
|
||||
payload.data?.card,
|
||||
payload.data,
|
||||
)
|
||||
return {
|
||||
card: issue ? compactIssue(issue) : null,
|
||||
source: 'ops-agent-gateway',
|
||||
context: plan.opsContext || null,
|
||||
rawOk: payload.ok ?? null,
|
||||
}
|
||||
}
|
||||
if (actionId === 'ops.card.add_comment') {
|
||||
const comment = firstObject(
|
||||
payload.comment,
|
||||
payload.issueComment,
|
||||
payload.data?.comment,
|
||||
payload.data?.issueComment,
|
||||
payload.data,
|
||||
)
|
||||
return {
|
||||
comment: comment ? compactComment(comment) : null,
|
||||
source: 'ops-agent-gateway',
|
||||
context: plan.opsContext || null,
|
||||
rawOk: payload.ok ?? null,
|
||||
}
|
||||
}
|
||||
if (actionId !== 'ops.card.list_recent') return payload
|
||||
|
||||
const limit = Number(plan?.opsContext?.limit || 5)
|
||||
const issues = firstIssueArray(payload)
|
||||
.slice()
|
||||
.sort((a, b) => issueTimestamp(b) - issueTimestamp(a))
|
||||
.slice(0, Math.max(1, Math.min(20, Number.isFinite(limit) ? limit : 5)))
|
||||
.map(compactIssue)
|
||||
|
||||
return {
|
||||
counts: {
|
||||
cards: issues.length,
|
||||
},
|
||||
latestCard: issues[0] || null,
|
||||
cards: issues,
|
||||
source: 'ops-agent-gateway',
|
||||
context: plan.opsContext || null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExecutionPayload(plan, payload) {
|
||||
if (plan?.adapterId === OPS_PRODUCT_API_ADAPTER_ID) {
|
||||
return normalizeOpsPayload(plan, payload)
|
||||
}
|
||||
return normalizeControlPlanePayload(plan, payload)
|
||||
}
|
||||
|
||||
function normalizeControlPlanePayload(plan, payload) {
|
||||
if (!payload || typeof payload !== 'object') return payload
|
||||
const actionId = planActionId(plan)
|
||||
const counts = payload.counts && typeof payload.counts === 'object' ? payload.counts : {}
|
||||
const accessRequests = dataArray(payload, 'accessRequests')
|
||||
const pendingAccessRequests = accessRequests.filter((item) => isPendingLikeStatus(item?.status))
|
||||
const engineRequests = dataArray(payload, 'engineWorkflowAccessRequests')
|
||||
const pendingEngineRequests = engineRequests.filter((item) => isPendingLikeStatus(item?.status))
|
||||
const invites = dataArray(payload, 'invites')
|
||||
const pendingInvites = invites.filter((item) => isPendingLikeStatus(item?.status))
|
||||
|
||||
if (actionId === 'hub.access_request.list_pending') {
|
||||
return {
|
||||
actor: payload.actor || null,
|
||||
counts: {
|
||||
accessRequests: counts.accessRequests ?? accessRequests.length,
|
||||
engineWorkflowAccessRequests: counts.engineWorkflowAccessRequests ?? engineRequests.length,
|
||||
},
|
||||
summary: {
|
||||
accessRequests: accessRequests.length,
|
||||
pendingAccessRequests: pendingAccessRequests.length,
|
||||
engineWorkflowAccessRequests: engineRequests.length,
|
||||
pendingEngineWorkflowAccessRequests: pendingEngineRequests.length,
|
||||
newAccessRequests: pendingAccessRequests.map((item) => item.email).filter(Boolean),
|
||||
},
|
||||
accessRequests: pendingAccessRequests.map(compactAccessRequest),
|
||||
engineWorkflowAccessRequests: pendingEngineRequests.map(compactAccessRequest),
|
||||
}
|
||||
}
|
||||
|
||||
if (actionId === 'hub.invite.list_pending') {
|
||||
return {
|
||||
actor: payload.actor || null,
|
||||
counts: {
|
||||
invites: counts.invites ?? invites.length,
|
||||
},
|
||||
summary: {
|
||||
invites: invites.length,
|
||||
pendingInvites: pendingInvites.length,
|
||||
pendingInviteEmails: pendingInvites.map((item) => item.email || item.inviteeEmail).filter(Boolean),
|
||||
},
|
||||
invites: pendingInvites.map(compactInvite),
|
||||
}
|
||||
}
|
||||
|
||||
if (actionId === 'hub.user.read_admin_summary') {
|
||||
return {
|
||||
actor: payload.actor || null,
|
||||
counts,
|
||||
summary: {
|
||||
users: counts.users ?? dataArray(payload, 'users').length,
|
||||
memberships: counts.memberships ?? dataArray(payload, 'memberships').length,
|
||||
services: counts.services ?? dataArray(payload, 'services').length,
|
||||
grants: counts.grants ?? dataArray(payload, 'grants').length,
|
||||
invites: counts.invites ?? invites.length,
|
||||
accessRequests: counts.accessRequests ?? accessRequests.length,
|
||||
pendingAccessRequests: pendingAccessRequests.length,
|
||||
newAccessRequests: pendingAccessRequests.map((item) => item.email).filter(Boolean),
|
||||
},
|
||||
users: dataArray(payload, 'users').map(compactUser),
|
||||
memberships: dataArray(payload, 'memberships').map(compactMembership),
|
||||
accessRequests: pendingAccessRequests.map(compactAccessRequest),
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
export function validateExecutionPlan(plan) {
|
||||
const errors = []
|
||||
const warnings = []
|
||||
const request = plan?.request || {}
|
||||
const method = normalizeMethod(request.method)
|
||||
const path = String(request.path || '')
|
||||
const adapterId = plan?.adapterId || plan?.actionDecision?.action?.adapterId || ''
|
||||
|
||||
if (!plan?.ok) {
|
||||
errors.push('plan_not_ok')
|
||||
}
|
||||
if (!method) {
|
||||
errors.push('request_method_missing')
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
errors.push('delete_method_forbidden')
|
||||
}
|
||||
|
||||
if (adapterId === OPS_PRODUCT_API_ADAPTER_ID) {
|
||||
if (!path.startsWith('/api/v1/tools/')) {
|
||||
errors.push('request_path_not_ops_tools_api')
|
||||
}
|
||||
if (!['GET', 'POST'].includes(method)) {
|
||||
errors.push(`method_not_allowed:${method || 'empty'}`)
|
||||
}
|
||||
if (method && path && !isAllowedOpsRoute(method, path)) {
|
||||
errors.push(`route_not_allowlisted:${method} ${path}`)
|
||||
}
|
||||
if (method === 'GET' && !request.query?.project_id) {
|
||||
errors.push('ops_project_id_missing')
|
||||
}
|
||||
if (method === 'POST' && !request.body?.project_id) {
|
||||
errors.push('ops_project_id_missing')
|
||||
}
|
||||
} else {
|
||||
if (!path.startsWith('/api/admin/')) {
|
||||
errors.push('request_path_not_admin_api')
|
||||
}
|
||||
if (!['GET', 'PATCH'].includes(method)) {
|
||||
errors.push(`method_not_allowed:${method || 'empty'}`)
|
||||
}
|
||||
if (method && path && !isAllowedRoute(method, path)) {
|
||||
errors.push(`route_not_allowlisted:${method} ${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (WRITE_METHODS.has(method) && !headerValue(request.headers, 'Idempotency-Key')) {
|
||||
errors.push('write_requires_idempotency_key')
|
||||
}
|
||||
if (WRITE_METHODS.has(method) && plan?.safety?.confirmationAlreadyChecked !== true) {
|
||||
errors.push('write_requires_prior_confirmation_check')
|
||||
}
|
||||
if (plan?.actionDecision?.action?.riskLevel === 'destructive') {
|
||||
errors.push('destructive_action_forbidden')
|
||||
}
|
||||
if (plan?.actionDecision?.decision === 'forbidden') {
|
||||
errors.push('forbidden_action_decision')
|
||||
}
|
||||
if (plan?.safety?.noDeleteRouteMapped !== true) {
|
||||
warnings.push('no_delete_route_mapping_not_asserted')
|
||||
}
|
||||
if (plan?.safety?.sourceGuardedRoutesOnly !== true) {
|
||||
warnings.push('source_guarded_routes_only_not_asserted')
|
||||
}
|
||||
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
method,
|
||||
path,
|
||||
}
|
||||
}
|
||||
|
||||
async function executeHttpPlan(plan, options) {
|
||||
const request = plan.request
|
||||
const method = normalizeMethod(request.method)
|
||||
const url = buildRequestUrl(options.baseUrl, request)
|
||||
const headers = {
|
||||
...(request.headers || {}),
|
||||
...(options.headers || {}),
|
||||
}
|
||||
const init = {
|
||||
method,
|
||||
headers,
|
||||
}
|
||||
|
||||
if (method !== 'GET' && request.body !== undefined) {
|
||||
if (!headerValue(headers, 'Content-Type')) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
if (!headerValue(headers, 'Accept')) {
|
||||
headers.Accept = 'application/json'
|
||||
}
|
||||
init.body = JSON.stringify(request.body)
|
||||
}
|
||||
|
||||
const fetchImpl = options.fetchImpl || globalThis.fetch
|
||||
if (typeof fetchImpl !== 'function') {
|
||||
throw new Error('No fetch implementation available')
|
||||
}
|
||||
|
||||
const response = await fetchImpl(url, init)
|
||||
const payload = normalizeExecutionPayload(plan, await readResponsePayload(response))
|
||||
|
||||
return {
|
||||
ok: Boolean(response.ok),
|
||||
status: response.status,
|
||||
statusText: response.statusText || '',
|
||||
url: String(url),
|
||||
method,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
async function buildAssistantExecutionPlan(input = {}, options = {}) {
|
||||
const decision = options.decision || await resolveAssistantAction(input, options)
|
||||
const adapterId = decision?.action?.adapterId
|
||||
|
||||
if (adapterId === OPS_PRODUCT_API_ADAPTER_ID) {
|
||||
return buildOpsProductPlan(input, { ...options, decision })
|
||||
}
|
||||
|
||||
return buildHubLauncherAdminPlan(input, { ...options, decision })
|
||||
}
|
||||
|
||||
function executionBaseUrlForPlan(plan, options = {}) {
|
||||
if (plan?.adapterId === OPS_PRODUCT_API_ADAPTER_ID) {
|
||||
return normalizeHeaderString(options.opsGatewayBaseUrl) || endpointOrigin(options.opsEntitlementUrl)
|
||||
}
|
||||
return normalizeHeaderString(options.baseUrl)
|
||||
}
|
||||
|
||||
export async function executeAssistantAction(input = {}, options = {}) {
|
||||
const mode = options.mode || 'dry-run'
|
||||
const plan = options.plan || await buildAssistantExecutionPlan(input, options)
|
||||
const safety = validateExecutionPlan(plan)
|
||||
const confirmationEnvelope = buildWriteConfirmationEnvelope(plan)
|
||||
|
||||
if (!safety.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
decision: 'execution_blocked',
|
||||
reason: 'execution_safety_check_failed',
|
||||
safety,
|
||||
confirmationEnvelope,
|
||||
plan,
|
||||
}
|
||||
}
|
||||
|
||||
if (mode !== 'execute') {
|
||||
return {
|
||||
ok: true,
|
||||
decision: 'dry_run',
|
||||
reason: 'network_execution_not_requested',
|
||||
safety,
|
||||
confirmationEnvelope,
|
||||
plan,
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.allowNetworkExecution) {
|
||||
return {
|
||||
ok: false,
|
||||
decision: 'execution_blocked',
|
||||
reason: 'network_execution_not_enabled',
|
||||
safety,
|
||||
confirmationEnvelope,
|
||||
plan,
|
||||
}
|
||||
}
|
||||
|
||||
if ((plan.dryRunOnly || plan.adapterStatus !== 'implemented') && !options.allowPlannedAdapterExecution) {
|
||||
return {
|
||||
ok: false,
|
||||
decision: 'execution_blocked',
|
||||
reason: 'adapter_not_marked_implemented',
|
||||
safety,
|
||||
confirmationEnvelope,
|
||||
plan,
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = executionBaseUrlForPlan(plan, options)
|
||||
if (!baseUrl) {
|
||||
return {
|
||||
ok: false,
|
||||
decision: 'execution_blocked',
|
||||
reason: plan?.adapterId === OPS_PRODUCT_API_ADAPTER_ID ? 'ops_gateway_base_url_missing' : 'base_url_missing',
|
||||
safety,
|
||||
confirmationEnvelope,
|
||||
plan,
|
||||
}
|
||||
}
|
||||
|
||||
const confirmationCheck = verifyWriteConfirmationEnvelope(input, options, confirmationEnvelope)
|
||||
if (!confirmationCheck.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
decision: 'execution_blocked',
|
||||
reason: confirmationCheck.reason,
|
||||
safety,
|
||||
confirmationEnvelope,
|
||||
plan,
|
||||
}
|
||||
}
|
||||
|
||||
const gatewayHeaders = await buildGatewayHeaders(plan, options)
|
||||
if (!gatewayHeaders.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
decision: 'execution_blocked',
|
||||
reason: gatewayHeaders.reason,
|
||||
safety,
|
||||
confirmationEnvelope,
|
||||
plan,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await executeHttpPlan(plan, {
|
||||
...options,
|
||||
baseUrl,
|
||||
headers: {
|
||||
...(options.headers || {}),
|
||||
...gatewayHeaders.headers,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
ok: result.ok,
|
||||
decision: result.ok ? 'executed' : 'execution_failed',
|
||||
safety,
|
||||
confirmationEnvelope,
|
||||
plan,
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
async function readInputJson() {
|
||||
const inputIndex = process.argv.indexOf('--input-json')
|
||||
if (inputIndex >= 0) {
|
||||
const inputPath = process.argv[inputIndex + 1]
|
||||
if (!inputPath) throw new Error('--input-json requires a path')
|
||||
return JSON.parse(await fs.readFile(inputPath, 'utf8'))
|
||||
}
|
||||
|
||||
const inlineIndex = process.argv.indexOf('--input')
|
||||
if (inlineIndex >= 0) {
|
||||
return JSON.parse(process.argv[inlineIndex + 1] || '{}')
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function readArg(name) {
|
||||
const index = process.argv.indexOf(name)
|
||||
if (index === -1) return null
|
||||
return process.argv[index + 1] || null
|
||||
}
|
||||
|
||||
function assertSmoke(condition, message) {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
async function runSmoke() {
|
||||
const validation = await validateCatalog()
|
||||
assertSmoke(validation.ok, 'catalog must validate before executor smoke')
|
||||
|
||||
const blockInput = {
|
||||
actionId: 'hub.user.block',
|
||||
assistantRole: 'admin',
|
||||
membershipRole: 'client_admin',
|
||||
actorUserId: 'user_root',
|
||||
targetUserId: 'user_pupa',
|
||||
confirmed: true,
|
||||
idempotencyKey: 'smoke:execute:block:user_pupa',
|
||||
}
|
||||
|
||||
const dryRun = await executeAssistantAction(blockInput)
|
||||
const executeWithoutConfirmation = await executeAssistantAction(blockInput, {
|
||||
mode: 'execute',
|
||||
allowNetworkExecution: true,
|
||||
baseUrl: 'http://launcher.local.test',
|
||||
})
|
||||
const confirmedBlockInput = {
|
||||
...blockInput,
|
||||
confirmationToken: dryRun.confirmationEnvelope?.token,
|
||||
}
|
||||
const executeBlocked = await executeAssistantAction(confirmedBlockInput, {
|
||||
mode: 'execute',
|
||||
allowNetworkExecution: true,
|
||||
baseUrl: 'http://launcher.local.test',
|
||||
})
|
||||
|
||||
const mockCalls = []
|
||||
const mockFetch = async (url, init) => {
|
||||
mockCalls.push({ url: String(url), init })
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: { get: () => 'application/json' },
|
||||
text: async () => JSON.stringify({ ok: true, source: 'mock-launcher' }),
|
||||
}
|
||||
}
|
||||
|
||||
const mockExecuted = await executeAssistantAction(confirmedBlockInput, {
|
||||
mode: 'execute',
|
||||
allowNetworkExecution: true,
|
||||
baseUrl: 'http://launcher.local.test',
|
||||
launcherInternalToken: 'smoke-token',
|
||||
fetchImpl: mockFetch,
|
||||
})
|
||||
|
||||
const deleteBlocked = await executeAssistantAction({
|
||||
actionId: 'hub.user.delete',
|
||||
assistantRole: 'admin',
|
||||
membershipRole: 'client_admin',
|
||||
actorUserId: 'user_root',
|
||||
targetUserId: 'user_pupa',
|
||||
confirmed: true,
|
||||
}, {
|
||||
mode: 'execute',
|
||||
allowNetworkExecution: true,
|
||||
allowPlannedAdapterExecution: true,
|
||||
baseUrl: 'http://launcher.local.test',
|
||||
fetchImpl: mockFetch,
|
||||
})
|
||||
|
||||
const badDeletePlanSafety = validateExecutionPlan({
|
||||
ok: true,
|
||||
adapterStatus: 'implemented',
|
||||
dryRunOnly: false,
|
||||
safety: {
|
||||
noDeleteRouteMapped: false,
|
||||
sourceGuardedRoutesOnly: false,
|
||||
confirmationAlreadyChecked: true,
|
||||
},
|
||||
actionDecision: {
|
||||
decision: 'allowed',
|
||||
action: { riskLevel: 'privileged' },
|
||||
},
|
||||
request: {
|
||||
method: 'DELETE',
|
||||
path: '/api/admin/users/user_pupa',
|
||||
headers: { 'Idempotency-Key': 'bad-delete' },
|
||||
},
|
||||
})
|
||||
|
||||
assertSmoke(dryRun.decision === 'dry_run', 'default executor mode must be dry_run')
|
||||
assertSmoke(Boolean(dryRun.confirmationEnvelope?.token), 'dry-run write should return confirmation envelope token')
|
||||
assertSmoke(executeWithoutConfirmation.reason === 'write_confirmation_envelope_missing', 'write execution must require confirmation envelope token')
|
||||
assertSmoke(executeBlocked.reason === 'launcher_internal_token_missing', 'confirmed implemented gateway adapter must require an internal token')
|
||||
assertSmoke(mockExecuted.decision === 'executed', 'mock execution should execute with gateway auth headers')
|
||||
assertSmoke(mockCalls.length === 1, 'mock execution should issue exactly one request')
|
||||
assertSmoke(mockCalls[0].init.method === 'PATCH', 'mock execution should use PATCH')
|
||||
assertSmoke(mockCalls[0].url === 'http://launcher.local.test/api/admin/users/user_pupa/profile', 'mock execution should hit profile route')
|
||||
assertSmoke(mockCalls[0].init.headers.Authorization === 'Bearer smoke-token', 'mock execution should send internal bearer token')
|
||||
assertSmoke(mockCalls[0].init.headers['X-NODEDC-Assistant-Gateway'] === ASSISTANT_GATEWAY_NAME, 'mock execution should send assistant gateway header')
|
||||
assertSmoke(mockCalls[0].init.headers['X-NODEDC-Assistant-Actor-User-Id'] === 'user_root', 'mock execution should send actor user id')
|
||||
assertSmoke(deleteBlocked.decision === 'execution_blocked', 'delete action must block before network execution')
|
||||
assertSmoke(!badDeletePlanSafety.ok && badDeletePlanSafety.errors.includes('delete_method_forbidden'), 'DELETE plan must fail safety check')
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
validation: validation.counts,
|
||||
cases: {
|
||||
dryRun,
|
||||
executeWithoutConfirmation,
|
||||
executeBlocked,
|
||||
mockExecuted,
|
||||
deleteBlocked,
|
||||
badDeletePlanSafety,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
if (process.argv.includes('--smoke')) {
|
||||
console.log(JSON.stringify(await runSmoke(), null, 2))
|
||||
} else {
|
||||
const input = await readInputJson()
|
||||
if (!input) throw new Error('Use --smoke, --input <json>, or --input-json <path>')
|
||||
|
||||
const allowPlannedAdapterExecution =
|
||||
process.argv.includes('--allow-planned-adapter-execution') &&
|
||||
process.env.NDC_ASSISTANT_EXECUTOR_UNSAFE_DEV === '1'
|
||||
|
||||
const output = await executeAssistantAction(input, {
|
||||
mode: process.argv.includes('--execute') ? 'execute' : 'dry-run',
|
||||
allowNetworkExecution: process.argv.includes('--execute'),
|
||||
allowPlannedAdapterExecution,
|
||||
baseUrl: readArg('--base-url') || process.env.NDC_LAUNCHER_BASE_URL || null,
|
||||
confirmationToken: readArg('--confirmation-token') || null,
|
||||
launcherInternalToken:
|
||||
readArg('--launcher-internal-token') ||
|
||||
readArg('--internal-token') ||
|
||||
process.env.NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN ||
|
||||
process.env.NODEDC_INTERNAL_ACCESS_TOKEN ||
|
||||
process.env.NODEDC_PLATFORM_SERVICE_TOKEN ||
|
||||
null,
|
||||
})
|
||||
|
||||
console.log(JSON.stringify(output, null, 2))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user