diff --git a/infra/deploy-runner/build-engine-agent-full-grant-migration-artifact.mjs b/infra/deploy-runner/build-engine-agent-full-grant-migration-artifact.mjs index f11fde7..4aa4ff8 100644 --- a/infra/deploy-runner/build-engine-agent-full-grant-migration-artifact.mjs +++ b/infra/deploy-runner/build-engine-agent-full-grant-migration-artifact.mjs @@ -16,6 +16,12 @@ const storeRelativePath = 'nodedc-source/server/engineAgents/store.js' const predecessorSha256 = '52daa43499d6d9a97fe7ffa891edb9212b7791e733f91dd3ca686d42739b7e9a' const targetSha256 = '2e62654c2dc12905efcc83a9dff45a818dd7b47924a10600160835c4416540e9' const readerExtendedSourceSha256 = 'debd351fe0b8c72b33b8c79909b8f339cbaf061b970576f3ae72df52ebaa211f' +const ontologyExtendedSourceSha256 = '4cd4bdd5958cfafee184e98a04fe12aa0c1cbe884326beaec63c99f9fff61285' +const frozenTargetArtifact = join( + workspaceRoot, + 'deploy-artifacts/nodedc-engine-agent-full-grant-migration-20260717-002.tgz', +) +const frozenTargetArtifactSha256 = 'd104ee6e63d1fccb9069e2b3db5e0446907ee9bf9cb04f2387636c272f803d68' const previouslyIssuedPatchIds = new Set([ 'engine-agent-full-grant-migration-20260717-001', ]) @@ -30,7 +36,7 @@ if (previouslyIssuedPatchIds.has(patchId)) { const source = join(engineRoot, storeRelativePath) const sourceInfo = await lstat(source) if (sourceInfo.isSymbolicLink() || !sourceInfo.isFile()) throw new Error('engine_agent_store_source_unsafe') -const sourceBytes = materializeFrozenTarget(await readFile(source)) +const sourceBytes = await materializeFrozenTarget(await readFile(source)) if (digest(sourceBytes) !== targetSha256) throw new Error('engine_agent_store_target_sha256_mismatch') const sourceText = sourceBytes.toString('utf8') for (const required of [ @@ -121,9 +127,28 @@ function digest(bytes) { return createHash('sha256').update(bytes).digest('hex') } -function materializeFrozenTarget(bytes) { +async function materializeFrozenTarget(bytes) { const sourceSha256 = digest(bytes) if (sourceSha256 === targetSha256) return bytes + if (sourceSha256 === ontologyExtendedSourceSha256) { + const artifactBytes = await readFile(frozenTargetArtifact) + if (digest(artifactBytes) !== frozenTargetArtifactSha256) { + throw new Error('engine_agent_store_frozen_target_artifact_sha256_mismatch') + } + const script = [ + 'import pathlib,sys,tarfile', + 'p=pathlib.Path(sys.argv[1])', + "with tarfile.open(p,'r:gz') as t:", + " f=t.extractfile('payload/nodedc-source/server/engineAgents/store.js')", + " if f is None: raise SystemExit('member-unreadable')", + ' sys.stdout.buffer.write(f.read())', + ].join('\n') + const frozen = Buffer.from(run('python3', ['-c', script, frozenTargetArtifact]).stdout, 'utf8') + if (digest(frozen) !== targetSha256) { + throw new Error('engine_agent_store_frozen_target_sha256_mismatch') + } + return frozen + } if (sourceSha256 !== readerExtendedSourceSha256) { throw new Error('engine_agent_store_target_sha256_mismatch') } diff --git a/infra/deploy-runner/build-engine-mcp-ontology-sdk-artifact.mjs b/infra/deploy-runner/build-engine-mcp-ontology-sdk-artifact.mjs new file mode 100644 index 0000000..e213904 --- /dev/null +++ b/infra/deploy-runner/build-engine-mcp-ontology-sdk-artifact.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const platformRoot = resolve(here, '../..') +const engineRoot = resolve( + process.env.NODEDC_ENGINE_SOURCE_ROOT || resolve(platformRoot, '../NODEDC_ENGINE_INFRA'), +) +const canonicalArtifactRoot = resolve(here, '../deploy-artifacts') +const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || canonicalArtifactRoot) +const [transitionId = '20260718-005', ...extra] = process.argv.slice(2) +if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) { + throw new Error('usage: build-engine-mcp-ontology-sdk-artifact.mjs [YYYYMMDD-NNN]') +} + +const id = `engine-mcp-control-plane-${transitionId}` +const target = join(artifactRoot, `nodedc-${id}.tgz`) +const predecessorArtifact = join( + canonicalArtifactRoot, + 'nodedc-engine-mcp-control-plane-20260718-003.tgz', +) +const predecessorArtifactSha256 = '249aef9527666c562e9648b15737e66cc5c1dc7c0788b58ea714da270b5eb4ba' +const descriptorRel = 'nodedc-source/services/node-intelligence/activation.json' +const gatewayRel = 'nodedc-source/server/routes/engineAgentGateway.js' +const files = [ + 'nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs', + 'nodedc-source/server/assets/engine-agent-npm/package.json', + 'nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.5.tgz', + 'nodedc-source/server/assets/provider-packages/v1/catalog.json', + 'nodedc-source/server/dataProductPublishGrant/providerCatalog.js', + 'nodedc-source/server/engineAgents/store.js', + gatewayRel, + descriptorRel, +] +const expectedSha256 = new Map([ + [files[0], 'adff3e474c914680f7d36b204d1dc03e69dc8065fff1f80b6713526cfc970137'], + [files[1], '0741647e4f7f58f69f3021d367484609a8e1eb7b8727d6ad9639bd9906b7f455'], + [files[2], '72a1b2d41a12a298eaae63ba3334c7c66b6ca53de6a3f03a48607d4ff6da42fb'], + [files[3], '4800e1a1c2af5e4893b1a403c04e91f55689383457caff833edc9e35e8e7e37a'], + [files[4], 'd5511a8bd3b4238af88a89537661c9ca4c0126bca7b5ad225985e27ccdb2c64c'], + [files[5], '4cd4bdd5958cfafee184e98a04fe12aa0c1cbe884326beaec63c99f9fff61285'], + [files[6], '25fa013ddbfd7d0c7ece8c792daec5f345062757c85eb56cfbff45bf1e812152'], +]) + +await assertFresh(target) +assertSha(await readFile(predecessorArtifact), predecessorArtifactSha256, 'predecessor MCP artifact') +const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-mcp-ontology-sdk-')) +const payload = join(stage, 'payload') +try { + await mkdir(payload, { recursive: true }) + for (const rel of files.slice(0, -1)) { + const source = join(engineRoot, rel) + const stat = await lstat(source) + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`source_boundary_invalid:${rel}`) + assertSha(await readFile(source), expectedSha256.get(rel), rel) + await mkdir(dirname(join(payload, rel)), { recursive: true }) + await cp(source, join(payload, rel), { force: false }) + } + + const descriptor = JSON.parse(extractMember( + predecessorArtifact, + `payload/${descriptorRel}`, + )) + if ( + descriptor?.action !== 'activate' + || descriptor?.releaseId !== '2.33.2-974a9fb3492f' + || descriptor?.source?.gatewaySha256 !== '96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15' + || descriptor?.source?.upstreamProjectionSha256 !== '761a874b102a938bc6018159ddacdaac71ad6ae08e9f0f8d7f3b58a0165a5131' + ) throw new Error('mcp_control_plane_predecessor_descriptor_mismatch') + descriptor.source.gatewaySha256 = expectedSha256.get(gatewayRel) + await mkdir(dirname(join(payload, descriptorRel)), { recursive: true }) + await writeFile(join(payload, descriptorRel), `${JSON.stringify(descriptor, null, 2)}\n`, 'utf8') + + await writeFile(join(stage, 'manifest.env'), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, 'utf8') + await writeFile(join(stage, 'files.txt'), `${files.join('\n')}\n`, 'utf8') + await mkdir(artifactRoot, { recursive: true }) + run('python3', ['-c', canonicalTarScript(), target, stage]) + const artifactSha256 = sha(await readFile(target)) + console.log(JSON.stringify({ + ok: true, + id, + artifact: target, + artifactSha256, + services: ['nodedc-backend'], + predecessorGatewaySha256: '96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15', + targetGatewaySha256: expectedSha256.get(gatewayRel), + mcpVersion: '0.6.0', + installerVersion: '0.1.5', + ontologyMcp: 'separate-read-only-proxy', + providerPackage: 'gelios.provider.v2', + providerCredential: 'httpQueryAuth', + preserved: ['n8n', 'L1', 'node-intelligence image', 'databases', 'provider credential values'], + files, + }, null, 2)) +} finally { + await rm(stage, { recursive: true, force: true }) +} + +async function assertFresh(path) { + try { + await lstat(path) + } catch (error) { + if (error?.code === 'ENOENT') return + throw error + } + throw new Error('artifact_already_exists') +} + +function extractMember(archive, member) { + const script = [ + 'import pathlib,sys,tarfile', + 'p=pathlib.Path(sys.argv[1]); name=sys.argv[2]', + "with tarfile.open(p,'r:gz') as t:", + ' m=t.getmember(name)', + " if not m.isfile(): raise SystemExit('member-not-file')", + ' f=t.extractfile(m)', + " if f is None: raise SystemExit('member-unreadable')", + ' sys.stdout.buffer.write(f.read())', + ].join('\n') + return run('python3', ['-c', script, archive, member]).stdout +} + +function canonicalTarScript() { + return [ + 'import gzip,io,pathlib,sys,tarfile', + 'root=pathlib.Path(sys.argv[2])', + "with open(sys.argv[1],'wb') as out:", + " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", + " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", + ' for x in paths:', + ' info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())', + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", + ].join('\n') +} + +function assertSha(bytes, expected, label) { + const actual = sha(bytes) + if (!expected || actual !== expected) throw new Error(`${label.replaceAll(' ', '_')}_sha256_mismatch:${actual}`) +} + +function sha(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +function run(command, args) { + const result = spawnSync(command, args, { + encoding: 'utf8', + maxBuffer: 128 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }) + if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`) + return result +} diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index 5a69109..4efabe7 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -174,6 +174,36 @@ ENGINE_MCP_CONTROL_PLANE_NEW_PATHS = ( "nodedc-source/server/dataProductReadGrant/store.js", ENGINE_DATA_PRODUCT_READ_GRANT_OVERRIDE_REL, ) +ENGINE_MCP_ONTOLOGY_SDK_ARTIFACT_ENTRIES = ( + "nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs", + "nodedc-source/server/assets/engine-agent-npm/package.json", + "nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.5.tgz", + "nodedc-source/server/assets/provider-packages/v1/catalog.json", + "nodedc-source/server/dataProductPublishGrant/providerCatalog.js", + "nodedc-source/server/engineAgents/store.js", + ENGINE_NODE_INTELLIGENCE_GATEWAY_REL, + ENGINE_MCP_CONTROL_PLANE_DESCRIPTOR_REL, +) +ENGINE_MCP_ONTOLOGY_SDK_PREDECESSOR_SHA256 = { + "nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs": "521098de69fe288a56bd4158c849c1055ba24be08e19f7a4111618d0e8138445", + "nodedc-source/server/assets/engine-agent-npm/package.json": "cbe113e9b10bb84b9ccbffa3e261908e58a8430c11abe2cf4fd5304741b04597", + "nodedc-source/server/assets/provider-packages/v1/catalog.json": "30f89052b6557a2444550bd6e7eed30747b2d9b7ff3a22d079d9905fe0a04702", + "nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "901b8fad80018ce177b34ced804b39cb140a47e831414057f484296b373c651d", + "nodedc-source/server/engineAgents/store.js": "debd351fe0b8c72b33b8c79909b8f339cbaf061b970576f3ae72df52ebaa211f", + ENGINE_NODE_INTELLIGENCE_GATEWAY_REL: "96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15", +} +ENGINE_MCP_ONTOLOGY_SDK_TARGET_SHA256 = { + "nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs": "adff3e474c914680f7d36b204d1dc03e69dc8065fff1f80b6713526cfc970137", + "nodedc-source/server/assets/engine-agent-npm/package.json": "0741647e4f7f58f69f3021d367484609a8e1eb7b8727d6ad9639bd9906b7f455", + "nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.5.tgz": "72a1b2d41a12a298eaae63ba3334c7c66b6ca53de6a3f03a48607d4ff6da42fb", + "nodedc-source/server/assets/provider-packages/v1/catalog.json": "4800e1a1c2af5e4893b1a403c04e91f55689383457caff833edc9e35e8e7e37a", + "nodedc-source/server/dataProductPublishGrant/providerCatalog.js": "d5511a8bd3b4238af88a89537661c9ca4c0126bca7b5ad225985e27ccdb2c64c", + "nodedc-source/server/engineAgents/store.js": "4cd4bdd5958cfafee184e98a04fe12aa0c1cbe884326beaec63c99f9fff61285", + ENGINE_NODE_INTELLIGENCE_GATEWAY_REL: "25fa013ddbfd7d0c7ece8c792daec5f345062757c85eb56cfbff45bf1e812152", +} +ENGINE_MCP_ONTOLOGY_SDK_NEW_PATHS = ( + "nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.5.tgz", +) ENGINE_CONTROL_PLANE_STATE_REL = "nodedc-control-plane" ENGINE_PUBLISH_GRANT_STATE_REL = f"{ENGINE_CONTROL_PLANE_STATE_REL}/publish-grants" ENGINE_READ_GRANT_STATE_REL = f"{ENGINE_CONTROL_PLANE_STATE_REL}/read-grants" @@ -2813,6 +2843,7 @@ def validate_engine_node_intelligence_transition(payload_dir, entries): "const ENGINE_AGENT_MCP_VERSION = '0.3.0'", "const ENGINE_AGENT_MCP_VERSION = '0.4.0'", "const ENGINE_AGENT_MCP_VERSION = '0.5.0'", + "const ENGINE_AGENT_MCP_VERSION = '0.6.0'", ) ): die("Engine node-intelligence gateway MCP version is not registered") @@ -3073,6 +3104,211 @@ process.stdout.write('engine-mcp-reader-grant:0.5.0:'+required.length+':'+scopes } +def is_engine_mcp_ontology_sdk_slice(component, entries): + return ( + component == "engine" + and entries is not None + and tuple(entries) == ENGINE_MCP_ONTOLOGY_SDK_ARTIFACT_ENTRIES + ) + + +def validate_engine_mcp_ontology_sdk_payload(payload_dir, entries): + if tuple(entries) != ENGINE_MCP_ONTOLOGY_SDK_ARTIFACT_ENTRIES: + die("Engine MCP Ontology/SDK files.txt exact set/order mismatch") + for rel, expected_sha256 in ENGINE_MCP_ONTOLOGY_SDK_TARGET_SHA256.items(): + path = payload_dir / rel + if path.is_symlink() or not path.is_file() or sha256_file(path) != expected_sha256: + die(f"Engine MCP Ontology/SDK target sha256 mismatch: {rel}") + + gateway = (payload_dir / ENGINE_NODE_INTELLIGENCE_GATEWAY_REL).read_text( + encoding="utf-8" + ) + installer_path = ( + payload_dir + / "nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs" + ) + installer = installer_path.read_text(encoding="utf-8") + package_path = payload_dir / "nodedc-source/server/assets/engine-agent-npm/package.json" + package = read_strict_json(package_path, "Engine MCP Codex installer package") + if package.get("name") != "@nodedc/engine-codex-agent" or package.get("version") != "0.1.5": + die("Engine MCP Ontology/SDK installer package identity mismatch") + if any( + marker not in gateway + for marker in ( + "const ENGINE_AGENT_MCP_VERSION = '0.6.0'", + "authenticateEngineAgentOntologyToken", + "apiRouter.post('/ontology-mcp'", + "process.env.NODEDC_INTERNAL_ACCESS_TOKEN", + "serverName: 'nodedc-engine-agent'", + "serverName: 'nodedc_ontology'", + ) + ): + die("Engine MCP separate Ontology gateway contract is incomplete") + if any( + marker not in installer + for marker in ( + "const ENGINE_SERVER_NAME = 'nodedc-engine-agent'", + "const ONTOLOGY_SERVER_NAME = 'nodedc_ontology'", + "Ontology tools/catalog are never embedded or multiplexed into Engine MCP", + "version: '0.1.5'", + ) + ): + die("Engine MCP separate Ontology installer contract is incomplete") + + archive_path = payload_dir / "nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.5.tgz" + expected_members = { + "package/bin/nodedc-engine-codex-agent.mjs": installer_path.read_bytes(), + "package/package.json": package_path.read_bytes(), + } + observed_members = {} + try: + with tarfile.open(archive_path, "r:gz") as archive: + for member in archive: + if not (member.isfile() or member.isdir()) or member.name.startswith("/"): + die("Engine MCP Ontology/SDK installer archive member is unsafe") + if member.isfile(): + source = archive.extractfile(member) + if source is None: + die("Engine MCP Ontology/SDK installer archive member is unreadable") + observed_members[member.name] = source.read(MAX_FILE_BYTES + 1) + except (OSError, tarfile.TarError): + die("Engine MCP Ontology/SDK installer archive is invalid") + if observed_members != expected_members: + die("Engine MCP Ontology/SDK installer archive/source equality mismatch") + + catalog = read_strict_json( + payload_dir / "nodedc-source/server/assets/provider-packages/v1/catalog.json", + "Engine provider security catalog", + ) + packages = catalog.get("packages") if isinstance(catalog, dict) else None + if not isinstance(packages, list) or len(packages) != 1: + die("Engine provider security catalog must contain one active package") + provider = packages[0] + capabilities = provider.get("capabilities") if isinstance(provider, dict) else None + capability = capabilities[0] if isinstance(capabilities, list) and len(capabilities) == 1 else None + request = capability.get("request") if isinstance(capability, dict) else None + if ( + provider.get("id") != "gelios.provider.v2" + or provider.get("version") != "2.0.0" + or (provider.get("providerCredential") or {}).get("credentialType") != "httpQueryAuth" + or not isinstance(request, dict) + or request.get("method") != "GET" + or request.get("url") != "https://admin.geliospro.com/sdk/?svc=get_units¶ms=%7B%7D" + ): + die("Engine Gelios SDK/query-auth security projection mismatch") + resolver = ( + payload_dir / "nodedc-source/server/dataProductPublishGrant/providerCatalog.js" + ).read_text(encoding="utf-8") + if "new Set(['httpBearerAuth', 'httpQueryAuth'])" not in resolver: + die("Engine provider credential type allowlist mismatch") + store = (payload_dir / "nodedc-source/server/engineAgents/store.js").read_text( + encoding="utf-8" + ) + if any( + marker not in store + for marker in ( + "const STORE_VERSION = 3", + "ontologyTokenHash", + "`ndc_eao_${", + "authenticateEngineAgentOntologyToken", + ) + ): + die("Engine separate Ontology token store migration is incomplete") + + descriptor = read_engine_node_intelligence_descriptor( + payload_dir / ENGINE_MCP_CONTROL_PLANE_DESCRIPTOR_REL, + "Engine MCP Ontology/SDK node-intelligence descriptor", + ) + if ( + descriptor.get("action") != "activate" + or descriptor["source"].get("gatewaySha256") + != ENGINE_MCP_ONTOLOGY_SDK_TARGET_SHA256[ENGINE_NODE_INTELLIGENCE_GATEWAY_REL] + or descriptor["source"].get("upstreamProjectionSha256") + != ENGINE_MCP_CONTROL_PLANE_TARGET_SHA256[ + "nodedc-source/server/nodeIntelligence/upstreamProjection.js" + ] + ): + die("Engine MCP Ontology/SDK descriptor target mismatch") + return descriptor + + +def preflight_engine_mcp_ontology_sdk_predecessor(payload_dir): + candidate = validate_engine_mcp_ontology_sdk_payload( + payload_dir, + ENGINE_MCP_ONTOLOGY_SDK_ARTIFACT_ENTRIES, + ) + installed = current_engine_node_intelligence_descriptor() + validate_installed_engine_node_intelligence_source(installed) + if installed is None or installed.get("action") != "activate": + die("Engine MCP Ontology/SDK requires active node intelligence") + expected_candidate = json.loads(json.dumps(installed)) + expected_candidate["source"]["gatewaySha256"] = ( + ENGINE_MCP_ONTOLOGY_SDK_TARGET_SHA256[ENGINE_NODE_INTELLIGENCE_GATEWAY_REL] + ) + if candidate != expected_candidate: + die("Engine MCP Ontology/SDK descriptor crosses node-intelligence source boundary") + root = component_root("engine") + for rel, expected_sha256 in ENGINE_MCP_ONTOLOGY_SDK_PREDECESSOR_SHA256.items(): + path = root / rel + if path.is_symlink() or not path.is_file() or sha256_file(path) != expected_sha256: + die(f"Engine MCP Ontology/SDK predecessor drift detected: {rel}") + for rel in ENGINE_MCP_ONTOLOGY_SDK_NEW_PATHS: + path = root / rel + if path.exists() or path.is_symlink(): + die(f"Engine MCP Ontology/SDK new path already exists: {rel}") + backend = preflight_engine_credential_backend_runtime() + if backend["mode"] != "verified-derived-retry": + die("Engine MCP Ontology/SDK requires the active immutable backend runtime") + return { + "descriptor": candidate, + "predecessor_gateway_sha256": installed["source"]["gatewaySha256"], + "target_gateway_sha256": candidate["source"]["gatewaySha256"], + "backend_mode": backend["mode"], + } + + +def accept_engine_mcp_ontology_sdk_runtime(): + root = component_root("engine") + descriptor = validate_engine_mcp_ontology_sdk_payload( + root, + ENGINE_MCP_ONTOLOGY_SDK_ARTIFACT_ENTRIES, + ) + installed = current_engine_node_intelligence_descriptor() + if installed != descriptor: + die("Engine MCP Ontology/SDK installed descriptor equality failed") + validate_installed_engine_node_intelligence_source(installed) + backend = preflight_engine_credential_backend_runtime() + if backend["mode"] != "verified-derived-retry": + die("Engine MCP Ontology/SDK backend runtime acceptance failed") + live = run_engine_backend_probe( + ( + "node", + "--input-type=module", + "-e", + """ +const gateway=await import('file:///app/server/routes/engineAgentGateway.js'); +const store=await import('file:///app/server/engineAgents/store.js'); +const catalog=JSON.parse(await (await import('node:fs/promises')).readFile('/app/server/assets/provider-packages/v1/catalog.json','utf8')); +const provider=catalog.packages?.[0]; +if(gateway.ENGINE_AGENT_MCP_VERSION!=='0.6.0')process.exit(2); +if(typeof store.authenticateEngineAgentOntologyToken!=='function')process.exit(3); +if(provider?.id!=='gelios.provider.v2'||provider?.providerCredential?.credentialType!=='httpQueryAuth')process.exit(4); +process.stdout.write('engine-mcp-ontology-sdk:0.6.0:0.1.5:gelios.provider.v2:httpQueryAuth'); +""".strip(), + ), + "Engine MCP Ontology/SDK capability", + container_id=engine_backend_container_id(), + ) + expected = "engine-mcp-ontology-sdk:0.6.0:0.1.5:gelios.provider.v2:httpQueryAuth" + if live != expected: + die("Engine MCP Ontology/SDK live capability acceptance mismatch") + return { + "gateway_sha256": descriptor["source"]["gatewaySha256"], + "backend_mode": backend["mode"], + "capability": live, + } + + def validate_no_lifecycle_scripts(payload_dir, label): forbidden = ("preinstall", "install", "postinstall", "prepare", "prepack", "postpack") for package_path in payload_dir.rglob("package.json"): @@ -3491,16 +3727,20 @@ def load_artifact(artifact, work_dir): touches_node_intelligence and not is_engine_node_intelligence_transition(manifest["component"], entries) and not is_engine_mcp_control_plane_slice(manifest["component"], entries) + and not is_engine_mcp_ontology_sdk_slice(manifest["component"], entries) ): die("Engine node-intelligence payload requires the canonical transition") if is_engine_node_intelligence_transition(manifest["component"], entries): validate_engine_node_intelligence_transition(payload_dir, entries) if is_engine_mcp_control_plane_slice(manifest["component"], entries): validate_engine_mcp_control_plane_payload(payload_dir, entries) + if is_engine_mcp_ontology_sdk_slice(manifest["component"], entries): + validate_engine_mcp_ontology_sdk_payload(payload_dir, entries) if touches_engine_credential_sink(manifest["component"], entries): validate_engine_credential_sink_slice(payload_dir, entries) if ( not is_engine_mcp_control_plane_slice(manifest["component"], entries) + and not is_engine_mcp_ontology_sdk_slice(manifest["component"], entries) and ( touches_engine_data_product_publish_grant(entries) or ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL in entries @@ -3509,6 +3749,7 @@ def load_artifact(artifact, work_dir): validate_engine_data_product_publish_grant_slice(payload_dir, entries) elif ( not is_engine_mcp_control_plane_slice(manifest["component"], entries) + and not is_engine_mcp_ontology_sdk_slice(manifest["component"], entries) and ENGINE_AGENT_FULL_GRANT_MIGRATION_STORE_REL in entries ): validate_engine_agent_full_grant_migration_slice(payload_dir, entries) @@ -3857,7 +4098,10 @@ def is_platform_provider_catalog_only(entries): def component_services(component, entries=None): - if is_engine_mcp_control_plane_slice(component, entries): + if ( + is_engine_mcp_control_plane_slice(component, entries) + or is_engine_mcp_ontology_sdk_slice(component, entries) + ): # This slice updates only the existing Engine backend control plane. # Node intelligence keeps the same immutable sidecar image and n8n/L1 # retain their current generations. @@ -5992,6 +6236,7 @@ def plan_artifact(artifact): publish_grant_preflight = None node_intelligence_preflight = None mcp_control_plane_preflight = None + mcp_ontology_sdk_preflight = None with tempfile.TemporaryDirectory(prefix="plan-", dir=TMP_DIR) as tmp: manifest, entries, payload_dir = load_artifact(artifact, Path(tmp)) transition_descriptor = None @@ -6016,6 +6261,10 @@ def plan_artifact(artifact): mcp_control_plane_preflight = preflight_engine_mcp_control_plane_predecessor( payload_dir ) + if is_engine_mcp_ontology_sdk_slice(manifest["component"], entries): + mcp_ontology_sdk_preflight = preflight_engine_mcp_ontology_sdk_predecessor( + payload_dir + ) component = manifest["component"] root = component_root(component) @@ -6098,6 +6347,24 @@ def plan_artifact(artifact): print(f"backend_current_barrier={mcp_control_plane_preflight['backend_mode']}") print("node_intelligence_image=preserved") print("n8n_l1=untouched") + if mcp_ontology_sdk_preflight: + print("engine_mcp_transition=separate-ontology+gelios-sdk-query-auth") + print("engine_mcp_version=0.6.0") + print("engine_mcp_installer=0.1.5") + print("engine_mcp_ontology=separate-read-only-proxy") + print("engine_provider_package=gelios.provider.v2") + print("engine_provider_credential=httpQueryAuth") + print( + "predecessor_gateway_sha256=" + f"{mcp_ontology_sdk_preflight['predecessor_gateway_sha256']}" + ) + print( + "target_gateway_sha256=" + f"{mcp_ontology_sdk_preflight['target_gateway_sha256']}" + ) + print(f"backend_current_barrier={mcp_ontology_sdk_preflight['backend_mode']}") + print("node_intelligence_image=preserved") + print("n8n_l1=untouched") if transition_descriptor: print(f"n8n_transition={transition_descriptor['action']}") print(f"n8n_version={transition_descriptor['n8nVersion']}") @@ -7049,6 +7316,7 @@ def component_healthchecks(component, entries=None, services=None): is_engine_data_product_publish_grant_slice(component, entries) or is_engine_agent_full_grant_migration_slice(component, entries) or is_engine_mcp_control_plane_slice(component, entries) + or is_engine_mcp_ontology_sdk_slice(component, entries) ) and services is not None ): @@ -7362,11 +7630,13 @@ def run_healthchecks(component, entries=None, services=None): entries, ) touches_mcp_control_plane = is_engine_mcp_control_plane_slice(component, entries) + touches_mcp_ontology_sdk = is_engine_mcp_ontology_sdk_slice(component, entries) if ( touches_engine_credential_sink(component, entries) or touches_publish_grant or touches_agent_grant_migration or touches_mcp_control_plane + or touches_mcp_ontology_sdk ): # The HTTP endpoint can become ready before Docker publishes the first # successful health probe. Wait for the Compose health barrier before @@ -7379,6 +7649,7 @@ def run_healthchecks(component, entries=None, services=None): or touches_publish_grant or touches_agent_grant_migration or touches_mcp_control_plane + or touches_mcp_ontology_sdk ): runtime = preflight_engine_credential_backend_runtime() if runtime["mode"] != "verified-derived-retry": @@ -7387,6 +7658,8 @@ def run_healthchecks(component, entries=None, services=None): accept_engine_agent_full_grant_migration_runtime() if touches_mcp_control_plane: accept_engine_mcp_control_plane_runtime() + if touches_mcp_ontology_sdk: + accept_engine_mcp_ontology_sdk_runtime() container_name = COMPONENTS[component].get("health_container") if container_name: healthcheck_container(container_name) @@ -7463,6 +7736,8 @@ def apply_artifact(artifact): node_intelligence_descriptor = node_intelligence_preflight["descriptor"] if is_engine_mcp_control_plane_slice(component, entries): preflight_engine_mcp_control_plane_predecessor(payload_dir) + if is_engine_mcp_ontology_sdk_slice(component, entries): + preflight_engine_mcp_ontology_sdk_predecessor(payload_dir) if not artifact_only and not compose_root.is_dir(): if bootstrap_root and is_relative_to(compose_root.resolve(strict=False), root.resolve(strict=False)): compose_root.mkdir(parents=True, exist_ok=True) @@ -7640,6 +7915,7 @@ def apply_artifact(artifact): is_engine_data_product_publish_grant_slice(component, entries) or is_engine_agent_full_grant_migration_slice(component, entries) or is_engine_mcp_control_plane_slice(component, entries) + or is_engine_mcp_ontology_sdk_slice(component, entries) ) and services is not None ): diff --git a/infra/deploy-runner/test_engine_mcp_ontology_sdk.py b/infra/deploy-runner/test_engine_mcp_ontology_sdk.py new file mode 100644 index 0000000..4ea28ba --- /dev/null +++ b/infra/deploy-runner/test_engine_mcp_ontology_sdk.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +import hashlib +import importlib.machinery +import importlib.util +import json +import os +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).resolve().parent +PLATFORM_ROOT = SCRIPT_DIR.parent.parent +RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" +ENGINE_BUILDER = SCRIPT_DIR / "build-engine-mcp-ontology-sdk-artifact.mjs" +PLATFORM_BUILDER = SCRIPT_DIR / "build-platform-gelios-provider-v2-artifact.mjs" +ENGINE_ARTIFACT = ( + PLATFORM_ROOT + / "infra/deploy-artifacts/nodedc-engine-mcp-control-plane-20260718-005.tgz" +) +PLATFORM_ARTIFACT = ( + PLATFORM_ROOT + / "infra/deploy-artifacts/nodedc-platform-gelios-provider-v2-20260718-005.tgz" +) +ENGINE_SHA256 = "8d6e1581ebbba420984e2393b8c6f0f21e0b644f630c4ca6483a7cc4a0ae5c71" +PLATFORM_SHA256 = "e1b838ec265369af735766fc7d1bf23c5e05bd2d15ef65e2446b4f7efa7a1baa" + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader("nodedc_engine_mcp_ontology_sdk", str(RUNNER_PATH)) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +RUNNER = load_runner() + + +class EngineMcpOntologySdkTest(unittest.TestCase): + def test_canonical_artifacts_have_pinned_digests_and_safe_members(self): + for artifact, expected in ( + (ENGINE_ARTIFACT, ENGINE_SHA256), + (PLATFORM_ARTIFACT, PLATFORM_SHA256), + ): + self.assertEqual(hashlib.sha256(artifact.read_bytes()).hexdigest(), expected) + self.assertEqual(artifact.read_bytes()[4:8], b"\0\0\0\0") + with tarfile.open(artifact, "r:gz") as archive: + for member in archive: + self.assertTrue(member.isfile() or member.isdir()) + self.assertFalse(Path(member.name).name.startswith("._")) + + def test_engine_builder_is_byte_reproducible(self): + outputs = [] + with tempfile.TemporaryDirectory(prefix="nodedc-engine-mcp-ontology-sdk-") as directory: + for index in range(2): + target = Path(directory) / str(index) + target.mkdir() + env = os.environ.copy() + env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(target) + result = subprocess.run( + ["node", str(ENGINE_BUILDER), "20260718-005"], + cwd=PLATFORM_ROOT, + env=env, + check=True, + capture_output=True, + text=True, + ) + outputs.append(Path(json.loads(result.stdout)["artifact"]).read_bytes()) + self.assertEqual(outputs[0], outputs[1]) + self.assertEqual(outputs[0], ENGINE_ARTIFACT.read_bytes()) + + def test_platform_builder_is_byte_reproducible(self): + outputs = [] + with tempfile.TemporaryDirectory(prefix="nodedc-platform-gelios-v2-") as directory: + for index in range(2): + target = Path(directory) / str(index) + target.mkdir() + env = os.environ.copy() + env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(target) + result = subprocess.run( + ["node", str(PLATFORM_BUILDER), "20260718-005"], + cwd=PLATFORM_ROOT, + env=env, + check=True, + capture_output=True, + text=True, + ) + outputs.append(Path(json.loads(result.stdout)["artifact"]).read_bytes()) + self.assertEqual(outputs[0], outputs[1]) + self.assertEqual(outputs[0], PLATFORM_ARTIFACT.read_bytes()) + + def test_engine_slice_is_exact_and_restarts_only_backend(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manifest, entries, payload = RUNNER.load_artifact(ENGINE_ARTIFACT, root) + descriptor = RUNNER.validate_engine_mcp_ontology_sdk_payload(payload, entries) + self.assertEqual(manifest["component"], "engine") + self.assertEqual(tuple(entries), RUNNER.ENGINE_MCP_ONTOLOGY_SDK_ARTIFACT_ENTRIES) + self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",)) + self.assertEqual(RUNNER.component_builds("engine", entries), ()) + self.assertEqual( + descriptor["source"]["gatewaySha256"], + RUNNER.ENGINE_MCP_ONTOLOGY_SDK_TARGET_SHA256[ + RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL + ], + ) + + def test_platform_provider_package_is_metadata_only(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manifest, entries, _payload = RUNNER.load_artifact(PLATFORM_ARTIFACT, root) + self.assertEqual(manifest["component"], "platform") + self.assertTrue(RUNNER.is_platform_provider_catalog_only(entries)) + self.assertEqual(RUNNER.component_services("platform", entries), ()) + self.assertEqual(RUNNER.component_builds("platform", entries), ()) + self.assertEqual(RUNNER.component_healthchecks("platform", entries, ()), ()) + + def test_engine_payload_tampering_is_rejected(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + RUNNER.safe_extract(ENGINE_ARTIFACT, root) + entries = RUNNER.parse_files_list(root / "files.txt") + catalog = root / "payload/nodedc-source/server/assets/provider-packages/v1/catalog.json" + catalog.write_text("{}\n", encoding="utf-8") + with self.assertRaisesRegex(RUNNER.DeployError, "target sha256 mismatch"): + RUNNER.validate_engine_mcp_ontology_sdk_payload(root / "payload", entries) + + +if __name__ == "__main__": + unittest.main(verbosity=2)