diff --git a/tests/epiphany-operator-worker.test.mjs b/tests/epiphany-operator-worker.test.mjs index 3109b24..740510e 100644 --- a/tests/epiphany-operator-worker.test.mjs +++ b/tests/epiphany-operator-worker.test.mjs @@ -5,7 +5,7 @@ import { createHash } from "node:crypto"; import { createSocket } from "node:dgram"; import { spawn } from "node:child_process"; import { createRequire } from "node:module"; -import { mkdtemp,writeFile } from "node:fs/promises"; +import { mkdtemp,readFile,stat,writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { buildOperatorDefinitions,resultReceiptTuple } from "../tools/epiphany-operator-command-documents.mjs"; @@ -17,5 +17,6 @@ import { DurableOperatorLedger } from "../tools/epiphany-operator-ledger.mjs"; import { EpiphanyOperatorDiscordOutbox } from "../tools/epiphany-operator-discord-outbox.mjs"; const requireCache=createRequire(resolve("E:/Projects/CultLib/packages/cultcache-ts/package.json")),requireNet=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")),cache=requireCache("cultcache-ts"),cultnet=requireNet("cultnet-ts"),msgpack=requireNet("@msgpack/msgpack"); +test("operator identity enrollment emits only a compact public trust anchor",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-enroll-")),key=resolve(root,"private","operator.seed"),anchor=resolve(root,"public","operator.msgpack"),run=spawn(process.execPath,[resolve("tools/epiphany-operator-worker.mjs"),"enroll-identity","--private-key",key,"--trust-anchor-out",anchor],{cwd:resolve("."),stdio:["ignore","pipe","pipe"]}),output=await collect(run);assert.equal(output.code,0,output.stderr);const pulse=JSON.parse(output.stdout.trim()),decoded=msgpack.decode(await readFile(anchor));assert.equal(pulse.schemaVersion,"bifrost.epiphany_operator_identity_enrollment.v0");assert.equal(pulse.privateStateExposed,false);assert.equal(decoded[0],"epiphany.host_identity_trust_anchor.v0");assert.equal(decoded.length,6);if(process.platform!=="win32"){assert.equal((await stat(key)).mode&0o777,0o600);assert.equal((await stat(anchor)).mode&0o777,0o640);}}); test("supervised worker consumes the typed inbox and durably records the sealed result",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-worker-")),requestPath=resolve(root,"requests.cc"),privatePath=resolve(root,"private.cc"),ledgerPath=resolve(root,"ledger.cc"),deliveryPath=resolve(root,"deliveries.cc"),keyPath=resolve(root,"bifrost.seed"),anchorPath=resolve(root,"executor.msgpack"),defs=buildOperatorDefinitions(cache.defineDocumentType),feedback=buildFeedbackDefinitions(cache.defineDocumentType),requests=cache.CultCache.builder().withDocumentType(defs.request).withGenericStore(new cache.SingleFileMessagePackBackingStore(requestPath)).build(),privateCache=cache.CultCache.builder().withDocumentType(feedback.actorLink).withGenericStore(new cache.SingleFileMessagePackBackingStore(privatePath)).build(),issued=new Date(),expires=new Date(issued.getTime()+60000),request={schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:"interaction-1",commandId:"interaction-1",nonce:"interaction-1",sourceEventId:"interaction-1",sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:"interaction-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:issued.toISOString(),expiresAt:expires.toISOString(),producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command:{kind:"status"}};await requests.put(defs.request,request.requestId,request);await privateCache.put(feedback.actorLink,"guild:owner",{schemaName:"bifrost.discord.actor_link",schemaVersion:"bifrost.discord.actor_link.v0",linkId:"guild:owner",guildId:"guild",discordAuthorId:"owner",bifrostActorId:"actor",heimdallActorRef:"heimdall:actor:1",linkedAt:issued.toISOString()});await enrollOperatorIdentity(keyPath);const executor=await enrollOperatorIdentity(resolve(root,"executor.seed")),anchor=trustAnchor(executor,issued.toISOString());await writeFile(anchorPath,msgpack.encode([anchor.schemaVersion,anchor.identityId,anchor.publicKey,anchor.assurance,anchor.identityCreatedAt,anchor.sourceIdentityRecordSha256]));const socket=createSocket("udp4");await new Promise((ok,fail)=>{socket.once("error",fail);socket.bind(0,"127.0.0.1",ok)});const server=new cultnet.CultNetRudpSocketTransportConnection({runtimeId:request.targetRuntimeId,socket,mode:"server",connectionId:0xe91f0001,resendDelayMs:25,resendPollMs:5});server.on("frame",frame=>{const wire=msgpack.decode(frame.payload),admission=msgpack.decode(wire.document.payload),completedAt=new Date(issued.getTime()+1000).toISOString(),result={schemaVersion:"epiphany.operator_command.result.v1",resultId:"result-1",commandId:admission.packet.commandId,packetSha256:admission.packetSha256,targetRuntimeId:admission.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt,privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded",reviews:[],reviewCandidateId:"",reviewDecision:""},receipt={schemaVersion:"epiphany.operator_command.sealed_result.v1",result,resultPayloadSha256:`sha256-${createHash("sha256").update(msgpack.encode(result)).digest("hex")}`,commandId:result.commandId,packetSha256:result.packetSha256,targetRuntimeId:result.targetRuntimeId,completedAt,providerIdentityId:executor.identityId,privateStateExposed:false,executorSignature:new Uint8Array()};receipt.executorSignature=new Uint8Array(signPurpose(executor,EPIPHANY_RESULT_SIGNING_PURPOSE,msgpack.encode(resultReceiptTuple(receipt))));server.send("schema",msgpack.encode({schemaVersion:"cultnet.document_put_raw.v0",messageId:"result-1",document:{schemaId:"epiphany.operator_command.sealed_result",recordKey:receipt.commandId,storedAt:receipt.completedAt,payloadEncoding:"messagepack",payload:msgpack.encode(receipt),sourceRuntimeId:receipt.targetRuntimeId,sourceAgentId:receipt.providerIdentityId,sourceRole:"epiphany-operator-command-executor",tags:["cultnet.transport.rudp.v0"]}}));});try{const port=socket.address().port,run=spawn(process.execPath,[resolve("tools/epiphany-operator-worker.mjs"),"once","--request-store",requestPath,"--private-store",privatePath,"--ledger-store",ledgerPath,"--delivery-store",deliveryPath,"--private-key",keyPath,"--epiphany-trust-anchor",anchorPath,"--epiphany-rudp",`rudp://127.0.0.1:${port}`,"--owner-discord-id","owner"],{cwd:resolve("."),stdio:["ignore","pipe","pipe"]}),output=await collect(run);assert.equal(output.code,0,output.stderr);const pulse=JSON.parse(output.stdout.trim());assert.equal(pulse.status,"completed");const ledger=await DurableOperatorLedger.open(ledgerPath,{cultcacheRuntime:cache}),outbox=await EpiphanyOperatorDiscordOutbox.open(deliveryPath,{cultcacheRuntime:cache,encode:msgpack.encode});assert.equal(ledger.getCompletion(request.commandId).providerIdentityId,executor.identityId);const delivery=outbox.get(request.requestId);assert.equal(delivery.status,"completed");assert.equal(delivery.discordInteractionId,request.sourceEventId);assert.match(delivery.requestPayloadSha256,/^sha256-[0-9a-f]{64}$/);assert.equal(delivery.privateStateExposed,false);}finally{server.close();}}); function collect(child){return new Promise(resolveExit=>{let stdout="",stderr="";child.stdout.on("data",x=>stdout+=x);child.stderr.on("data",x=>stderr+=x);child.on("exit",code=>resolveExit({code,stdout,stderr}));});} diff --git a/tools/epiphany-operator-worker.mjs b/tools/epiphany-operator-worker.mjs index 3749eb9..0e61f36 100644 --- a/tools/epiphany-operator-worker.mjs +++ b/tools/epiphany-operator-worker.mjs @@ -1,24 +1,26 @@ #!/usr/bin/env node import { createRequire } from "node:module"; import { existsSync } from "node:fs"; +import { chmod, mkdir, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { buildOperatorDefinitions } from "./epiphany-operator-command-documents.mjs"; import { buildFeedbackDefinitions } from "./persona-feedback-documents.mjs"; -import { admitOperatorRequest,loadOperatorIdentity,OperatorCommandPermanentRefusal } from "./epiphany-operator-commands.mjs"; +import { admitOperatorRequest,enrollOperatorIdentity,loadOperatorIdentity,OperatorCommandPermanentRefusal,trustAnchor } from "./epiphany-operator-commands.mjs"; import { EpiphanyOperatorCultNetRudpTransport,loadEpiphanyOperatorTrustAnchor } from "./epiphany-operator-cultnet-rudp.mjs"; import { DurableOperatorLedger } from "./epiphany-operator-ledger.mjs"; import { EpiphanyOperatorDiscordOutbox } from "./epiphany-operator-discord-outbox.mjs"; const {command,options}=parseArgs(process.argv.slice(2)); if(command==="help"){ - process.stdout.write("epiphany-operator-worker --request-store --private-store --ledger-store --delivery-store --private-key --epiphany-trust-anchor --epiphany-rudp --owner-discord-id [--runtime-id ] [--interval-ms ] [--timeout-ms ]\n"); + process.stdout.write("epiphany-operator-worker --request-store --private-store --ledger-store --delivery-store --private-key --epiphany-trust-anchor --epiphany-rudp --owner-discord-id [--trust-anchor-out ] [--runtime-id ] [--interval-ms ] [--timeout-ms ]\n"); process.exit(0); } await main().catch(error=>{process.stderr.write(`${error instanceof Error?error.message:String(error)}\n`);process.exitCode=1;}); async function main(){ - if(!["once","serve"].includes(command))throw new Error("operator worker command must be once or serve"); const runtime=loadRuntime(); + if(command==="enroll-identity")return enrollIdentity(runtime); + if(!["once","serve"].includes(command))throw new Error("operator worker command must be once, serve, or enroll-identity"); const operatorDefs=buildOperatorDefinitions(runtime.defineDocumentType); const feedbackDefs=buildFeedbackDefinitions(runtime.defineDocumentType); const requestStore=requiredOption("request-store","BIFROST_EPIPHANY_OPERATOR_REQUEST_STORE"); @@ -43,6 +45,17 @@ async function main(){ }while(true); } +async function enrollIdentity(runtime){ + const privatePath=resolve(requiredOption("private-key","BIFROST_EPIPHANY_OPERATOR_PRIVATE_KEY")); + const outputPath=resolve(requiredOption("trust-anchor-out","BIFROST_EPIPHANY_OPERATOR_TRUST_ANCHOR_OUT")); + if(privatePath===outputPath)throw new Error("operator private identity and public trust anchor must be physically distinct"); + const identity=await enrollOperatorIdentity(privatePath),anchor=trustAnchor(identity,new Date().toISOString()); + await mkdir(resolve(outputPath,".."),{recursive:true}); + await writeFile(outputPath,runtime.encode([anchor.schemaVersion,anchor.identityId,anchor.publicKey,anchor.assurance,anchor.identityCreatedAt,anchor.sourceIdentityRecordSha256]),{flag:"wx",mode:0o640}); + await chmod(outputPath,0o640); + process.stdout.write(`${JSON.stringify({schemaVersion:"bifrost.epiphany_operator_identity_enrollment.v0",identityId:identity.identityId,trustAnchor:outputPath,privateStateExposed:false})}\n`); +} + async function processPending({requests,privateCache,operatorDefs,feedbackDefs,ledger,outbox,ownerDiscordId,identity,transport,encode}){ await requests.pullAllBackingStores();await privateCache.pullAllBackingStores(); const pending=requests.getAll(operatorDefs.request).filter(request=>!outbox.isExactDelivery(request)).sort((a,b)=>a.issuedAt.localeCompare(b.issuedAt)); @@ -68,7 +81,7 @@ async function processPending({requests,privateCache,operatorDefs,feedbackDefs,l function summary(request,status,disposition,detail){return {requestId:request.requestId,commandId:request.commandId,status,disposition,detail};} function loadRuntime(){const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(projects,"CultLib")),cachePkg=resolve(cult,"packages","cultcache-ts","package.json"),netPkg=resolve(cult,"packages","cultnet-ts","package.json");if(!existsSync(cachePkg)||!existsSync(netPkg))throw new Error("CultLib CultCache/CultNet TypeScript runtimes are unavailable");const cr=createRequire(cachePkg),nr=createRequire(netPkg),cultcache=cr("cultcache-ts"),msgpack=nr("@msgpack/msgpack");return {cultcache,cultnet:nr("cultnet-ts"),msgpack,encode:msgpack.encode,decode:msgpack.decode,CultCache:cultcache.CultCache,SingleFileMessagePackBackingStore:cultcache.SingleFileMessagePackBackingStore,defineDocumentType:cultcache.defineDocumentType};} -function parseArgs(values){const command=values.shift()??"help",options={},allowed=new Set(["request-store","private-store","ledger-store","delivery-store","private-key","epiphany-trust-anchor","epiphany-rudp","owner-discord-id","runtime-id","interval-ms","timeout-ms"]);while(values.length){const flag=values.shift();if(!flag?.startsWith("--")||!values.length)throw new Error(`invalid operator worker option ${flag??""}`);const key=flag.slice(2);if(!allowed.has(key))throw new Error(`unknown operator worker option --${key}`);if(Object.hasOwn(options,key))throw new Error(`duplicate operator worker option --${key}`);options[key]=values.shift();}return {command,options};} +function parseArgs(values){const command=values.shift()??"help",options={},allowed=new Set(["request-store","private-store","ledger-store","delivery-store","private-key","trust-anchor-out","epiphany-trust-anchor","epiphany-rudp","owner-discord-id","runtime-id","interval-ms","timeout-ms"]);while(values.length){const flag=values.shift();if(!flag?.startsWith("--")||!values.length)throw new Error(`invalid operator worker option ${flag??""}`);const key=flag.slice(2);if(!allowed.has(key))throw new Error(`unknown operator worker option --${key}`);if(Object.hasOwn(options,key))throw new Error(`duplicate operator worker option --${key}`);options[key]=values.shift();}return {command,options};} function option(name,environment){return options[name]??process.env[environment];} function requiredOption(name,environment){const value=option(name,environment);if(typeof value!=="string"||!value.trim())throw new Error(`operator worker requires --${name} or ${environment}`);return value;} function integerOption(name,fallback,min,max){const raw=options[name],value=raw===undefined?fallback:Number(raw);if(!Number.isInteger(value)||valuemax)throw new Error(`operator worker --${name} must be ${min}..=${max}`);return value;}