995 lines
31 KiB
JavaScript
995 lines
31 KiB
JavaScript
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))
|
|
}
|
|
}
|