NODEDC_PLATFORM/services/ontology-core/src/assistant-action-executor.mjs

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))
}
}