diff --git a/.env.local.example b/.env.local.example index 8f7b01d..fad7427 100644 --- a/.env.local.example +++ b/.env.local.example @@ -3,10 +3,15 @@ COMPOSE_PROJECT_NAME=bifrost-local -# Epiphany operator admission. No live transport default is assumed. +# Epiphany operator admission. Endpoint and trust anchor are explicit; there is +# no localhost or identity-string fallback. BIFROST_EPIPHANY_OPERATOR_REQUEST_STORE=.bifrost/epiphany-operator-requests.cc +BIFROST_EPIPHANY_OPERATOR_LEDGER_STORE=.bifrost/epiphany-operator-ledger.cc +BIFROST_EPIPHANY_OPERATOR_DISCORD_DELIVERY_STORE=.bifrost/epiphany-operator-discord-deliveries.cc BIFROST_EPIPHANY_OPERATOR_PRIVATE_KEY=.bifrost/private/epiphany-operator-ed25519.seed +BIFROST_EPIPHANY_OPERATOR_RUDP=rudp://yggdrasil:49100 +BIFROST_EPIPHANY_EXECUTOR_TRUST_ANCHOR=.bifrost/trust/epiphany-operator-executor.cc BIFROST_EPIPHANY_OPERATOR_OWNER_DISCORD_ID= BIFROST_EPIPHANY_OPERATOR_TARGET_RUNTIME_ID=epiphany-yggdrasil -BIFROST_EPIPHANY_OPERATOR_RESULT_IDENTITY_ID= +BIFROST_PERSONA_FEEDBACK_PRIVATE_STORE=.bifrost/persona-feedback-private.cc BIFROST_EPIPHANY_OPERATOR_CULTMESH_URI= diff --git a/docs/bridge.md b/docs/bridge.md index 6f8c1a6..a5e4944 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -196,9 +196,9 @@ The command is a dry-run shape probe for public surfaces that are real enough to VoidBot remains the Discord observer and mention scheduler. A Face registry entry may opt into a remote `remotePersonaFeedbackTarget`; only then does VoidBot insert an immutable typed `voidbot.discord.persona_feedback_event.v0` observation into the dedicated `BIFROST_PERSONA_FEEDBACK_OBSERVATION_STORE_PATH`. This is an observation inbox, never an actuator queue. Insert collisions must contain exactly equal content. Its canonical path and physical file identity must differ from the outbound Discord-command provider store and all Bifrost-private state. Local Persona mention handling is committed first; observation export runs independently with three bounded idempotent attempts and cannot suppress the local response path. -Discord operator control is a separate authority. VoidBot may insert only a strict `voidbot.discord.epiphany_operator_request.v0` document containing one of `status`, `sleep`, `wake`, or `directive`, a fixed target runtime, Discord provenance, and a positive expiry of at most 60 seconds. Bifrost independently requires the exact configured Discord owner and the existing guild/actor link with a Heimdall actor reference. It derives the Rust field-order packet tuple, hashes its canonical MessagePack bytes, and signs the exact `BifrostOperatorCommandAdmission` tuple for purpose `bifrost.operator-command.delivery.v0` with a dedicated operator Ed25519 seed. The feedback seed is not reused. +Discord operator control is a separate authority. VoidBot may insert only a strict `voidbot.discord.epiphany_operator_request.v0` document containing one of `status`, `sleep`, `wake`, or `directive`, a fixed target runtime, Discord provenance, and a positive expiry of at most 60 seconds. Bifrost independently requires the exact configured Discord owner and the exact persisted guild/actor mapping from its private actor-link store. The actor link is the admitted mapping authority; its Heimdall reference remains provenance until a signed live Heimdall claim surface exists and is not represented as authorization. Bifrost derives the Rust field-order packet tuple, hashes its canonical MessagePack bytes, and signs the exact `BifrostOperatorCommandAdmission` tuple for purpose `bifrost.operator-command.delivery.v0` with a dedicated operator Ed25519 seed. The feedback seed is not reused. -Bifrost does not mount an Epiphany store and has no argv or shell escape hatch. Operator execution crosses a narrow authenticated transport port. The live CultNet RUDP request/reply adapter is still required; configuration deliberately has no localhost default. Until that adapter exists, admission fails closed. A returned `epiphany.operator_command.result.v0` must arrive under the configured Epiphany transport identity and match command id, packet digest, and target runtime. Bifrost never manufactures success. Ordinary Persona messages remain `feedback_only` and cannot enter this path. +Bifrost does not mount an Epiphany store and has no argv or shell escape hatch. One supervised worker serializes requests across the CultNet RUDP boundary and requires the trusted Epiphany executor anchor. Success requires a host-signed `epiphany.operator_command.sealed_result.v0` whose payload digest, command id, packet digest, target runtime, completion time, and executor identity verify exactly; a transport acknowledgement is not success. Admissions and sealed results are persisted in the typed Bifrost operator ledger. Permanent pre-admission refusals are separately terminalized there, while transport failures remain retryable. An expired request cannot mint a new admission, but an exact admission already durably recorded before expiry may be retransmitted to recover Epiphany's one idempotent result without depending on mutable actor-link or signer state. The worker publishes a separate immutable Discord-delivery projection bound to the original request digest and interaction id; it exposes only bounded result/refusal fields and sealed-result fingerprints, never raw signatures or private state. VoidBot reads that outbox and owns Discord response/checkpoint state. Bifrost never manufactures success. Ordinary Persona messages remain `feedback_only` and cannot enter this path. Bifrost admits an observation only when guild, channel, target runtime, canonical repository, Persona, producer, and producer runtime match a preconfigured `bifrost.persona_feedback.target_binding.v0` document in Bifrost's private provider store. That binding also owns source visibility, data classification, and the delivery-store target. Bifrost never infers public visibility from Discord identifiers. It classifies the author using its own `bifrost.discord.actor_link.v0` state: a match yields `linked_governance_feedback`; absence yields `unlinked_social_feedback`. Neither class grants adoption, work, release, or deployment authority. diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index 542655f..a8a504a 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -55,7 +55,9 @@ Required witness path for the hosted service: Bifrost publishes under these CultMesh namespaces: -The operator bridge additionally advertises `commands/epiphany-operator` and `receipts/epiphany-operator`. The command endpoint consumes strict VoidBot inbox documents and emits dedicated Bifrost-signed admissions; the receipt endpoint exposes only Epiphany-origin, command-bound results. The transport adapter is intentionally unresolved until a live authenticated CultNet RUDP request/reply implementation is installed on Yggdrasil. +The operator bridge additionally advertises `commands/epiphany-operator` and `receipts/epiphany-operator`. The command endpoint consumes strict VoidBot inbox documents and emits dedicated Bifrost-signed admissions. Bifrost sends each admission as a typed `cultnet.document_put_raw.v0` document over reliable ordered UDP and waits for the correlated application receipt; transport acknowledgement alone is never success. The receipt endpoint exposes only `epiphany.operator_command.sealed_result.v0`: an exact, bounded result whose payload digest and command, packet, runtime, completion time, and executor identity are covered by the trusted Epiphany host's Ed25519 signature. Missing, late, malformed, foreign-key, replay-colliding, or incorrectly bound results fail closed. + +One supervised `tools/epiphany-operator-worker.mjs serve` process owns this route. It reads the dedicated VoidBot request inbox and Bifrost's persisted actor-link mapping, serializes commands through one RUDP client authority, and persists admissions, sealed results, and non-retryable terminal refusals in `.bifrost/epiphany-operator-ledger.cc`. It then publishes one immutable `bifrost.discord.epiphany_operator_delivery.v0` document per request to `.bifrost/epiphany-operator-discord-deliveries.cc`; Bifrost owns publication and VoidBot owns correlation plus the ephemeral Discord response. The delivery binds the original request digest and interaction coordinates to either the verified sealed-result payload/signature/provider digests or a terminal refusal code, and never contains raw signature material or private state. The exact configured Discord owner plus that persisted actor mapping are the current admission authority; any Heimdall reference on the link is provenance, not a substituted capability claim. The worker requires an explicit raw MessagePack Epiphany executor trust anchor and an explicit `rudp://` endpoint. There is no shell, argv, direct Epiphany-store, localhost, identity-string, or transport-ack fallback. - `gamecult.bifrost.service` for service registration, health, build/version, schema catalog, and command capability discovery. diff --git a/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs b/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs index 9aa1c4f..1a026ca 100644 --- a/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs +++ b/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs @@ -23,7 +23,7 @@ public sealed class ProviderAdvertisementTests AssertEndpoint(root, "patron-support-intake", "bifrost.patron_support_event.v0", "patronage"); AssertEndpoint(root, "github-webhooks", "bifrost.work_item.v0", "github"); AssertEndpoint(root, "epiphany-operator-requests", "voidbot.discord.epiphany_operator_request.v0", "operator"); - AssertEndpoint(root, "epiphany-operator-results", "epiphany.operator_command.result.v0", "epiphany"); + AssertEndpoint(root, "epiphany-operator-results", "epiphany.operator_command.sealed_result.v0", "epiphany"); AssertSchemaPurpose(root, "bifrost.crossing_receipt.v1", "canonical Bifrost-owned public crossing receipt"); AssertSchemaPurpose(root, "bifrost.patron_support_event.v0", "current hosted Heimdall-signed patron support fact consumed by Bifrost"); AssertWitness(root, ".bifrost/bridge-receipts.cc", "bifrost.crossing_receipt.v1", "current"); diff --git a/tests/epiphany-operator-command.test.mjs b/tests/epiphany-operator-command.test.mjs index 2c7c9bc..afb473d 100644 --- a/tests/epiphany-operator-command.test.mjs +++ b/tests/epiphany-operator-command.test.mjs @@ -1,14 +1,17 @@ import test from "node:test";import assert from "node:assert/strict";import {mkdtemp} from "node:fs/promises";import {tmpdir} from "node:os";import {resolve} from "node:path";import {createRequire} from "node:module"; -import {admitOperatorRequest,enrollOperatorIdentity,ImmutableOperatorLedger,OPERATOR_SIGNING_PURPOSE} from "../tools/epiphany-operator-commands.mjs"; +import {admitOperatorRequest,enrollOperatorIdentity,OPERATOR_SIGNING_PURPOSE} from "../tools/epiphany-operator-commands.mjs"; import {admissionTuple,packetTuple,parseOperatorRequest} from "../tools/epiphany-operator-command-documents.mjs"; import {verifyPurpose} from "../tools/bifrost-signed-identity.mjs"; const requireCult=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")),{encode}=requireCult("@msgpack/msgpack"); const actorLink={guildId:"guild",discordAuthorId:"owner",bifrostActorId:"actor",heimdallActorRef:"heimdall:actor:1"}; function request(command={kind:"status"}){return {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:"channel",discordMessageId:"interaction-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T10:00:00.000Z",expiresAt:"2026-07-19T10:00:30.000Z",producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command};} -function result(admission){return {providerIdentityId:"epiphany-host",result:{schemaVersion:"epiphany.operator_command.result.v0",resultId:"result-1",commandId:admission.packet.commandId,packetSha256:admission.packetSha256,targetRuntimeId:admission.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt:"2026-07-19T10:00:01.000Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded"}};} +function result(admission){const value={schemaVersion:"epiphany.operator_command.result.v0",resultId:"result-1",commandId:admission.packet.commandId,packetSha256:admission.packetSha256,targetRuntimeId:admission.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt:"2026-07-19T10:00:01.000Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded"},receipt={schemaVersion:"epiphany.operator_command.sealed_result.v0",result:value,resultPayloadSha256:"sha256-result",commandId:value.commandId,packetSha256:value.packetSha256,targetRuntimeId:value.targetRuntimeId,completedAt:value.completedAt,providerIdentityId:"epiphany-host",privateStateExposed:false,executorSignature:new Uint8Array(64)};return {providerIdentityId:"epiphany-host",result:value,receipt};} +const transport=(execute)=>({trustedIdentityId:"epiphany-host",execute,verifyReceipt:(_admission,receipt)=>receipt}); +class TestLedger{constructor(){this.requests=new Map();this.admissions=new Map();this.completions=new Map();this.terminals=new Map();}admit(id,request,value){const oldRequest=this.requests.get(id),old=this.admissions.get(id);if(oldRequest&&JSON.stringify(oldRequest)!==JSON.stringify(request)||old&&JSON.stringify(old)!==JSON.stringify(value))throw new Error("operator request identity collision");this.requests.set(id,request);this.admissions.set(id,value);}complete(_id,result,_provider,receipt){this.completions.set(result.commandId,receipt??{...result,result,providerIdentityId:"epiphany-host"});}getRequest(id){return this.requests.get(id);}getAdmission(id){return this.admissions.get(id);}getCompletion(id){return this.completions.get(id);}terminalize(value){this.terminals.set(value.requestId,value);}getTerminal(id){return this.terminals.get(id);}} async function identity(){const d=await mkdtemp(resolve(tmpdir(),"bifrost-operator-"));return enrollOperatorIdentity(resolve(d,"operator.seed"));} -test("signs the exact Rust packet and admission tuples",async()=>{const id=await identity(),out=await admitOperatorRequest({request:request(),ownerDiscordId:"owner",actorLink,identity:id,encode,expectedEpiphanyIdentityId:"epiphany-host",now:new Date("2026-07-19T10:00:01Z"),transport:{execute:async a=>result(a)}});assert.equal(out.admission.packetSha256,`sha256-${(await import("node:crypto")).createHash("sha256").update(encode(packetTuple(out.admission.packet))).digest("hex")}`);assert.ok(verifyPurpose(id.publicKey,OPERATOR_SIGNING_PURPOSE,encode(admissionTuple(out.admission)),out.admission.providerSignature));assert.equal(out.result.disposition,"observed");}); -test("requires exact owner and linked Heimdall actor",async()=>{const id=await identity();for(const args of [{ownerDiscordId:"other",actorLink},{ownerDiscordId:"owner",actorLink:null},{ownerDiscordId:"owner",actorLink:{...actorLink,heimdallActorRef:""}}])await assert.rejects(()=>admitOperatorRequest({request:request(),identity:id,encode,expectedEpiphanyIdentityId:"epiphany-host",now:new Date("2026-07-19T10:00:01Z"),transport:{execute:async a=>result(a)},...args}));}); +test("signs the exact Rust packet and admission tuples",async()=>{const id=await identity(),out=await admitOperatorRequest({request:request(),ownerDiscordId:"owner",actorLink,identity:id,encode,now:new Date("2026-07-19T10:00:01Z"),ledger:new TestLedger(),transport:transport(async a=>result(a))});assert.equal(out.admission.packetSha256,`sha256-${(await import("node:crypto")).createHash("sha256").update(encode(packetTuple(out.admission.packet))).digest("hex")}`);assert.ok(verifyPurpose(id.publicKey,OPERATOR_SIGNING_PURPOSE,encode(admissionTuple(out.admission)),out.admission.providerSignature));assert.equal(out.result.disposition,"observed");}); +test("requires exact owner and admitted Bifrost actor link",async()=>{const id=await identity();for(const args of [{ownerDiscordId:"other",actorLink},{ownerDiscordId:"owner",actorLink:null}])await assert.rejects(()=>admitOperatorRequest({request:request(),identity:id,encode,now:new Date("2026-07-19T10:00:01Z"),ledger:new TestLedger(),transport:transport(async a=>result(a)),...args}));const provenanceOnly={...actorLink,heimdallActorRef:""};assert.equal((await admitOperatorRequest({request:request(),ownerDiscordId:"owner",actorLink:provenanceOnly,identity:id,encode,now:new Date("2026-07-19T10:00:01Z"),ledger:new TestLedger(),transport:transport(async a=>result(a))})).result.disposition,"observed");}); test("strict request, ttl, command bounds, and expiry fail closed",()=>{assert.throws(()=>parseOperatorRequest({...request(),extra:true}));assert.throws(()=>parseOperatorRequest({...request(),expiresAt:"2026-07-19T10:01:01Z"}));assert.throws(()=>parseOperatorRequest(request({kind:"argv",args:["whoami"]})));assert.throws(()=>parseOperatorRequest(request({kind:"sleep",reason:""})));}); -test("does not synthesize success without an authenticated transport result",async()=>{const id=await identity(),base={request:request(),ownerDiscordId:"owner",actorLink,identity:id,encode,expectedEpiphanyIdentityId:"epiphany-host",now:new Date("2026-07-19T10:00:01Z")};await assert.rejects(()=>admitOperatorRequest(base),/live CultNet RUDP adapter is missing/);await assert.rejects(()=>admitOperatorRequest({...base,transport:{execute:async a=>({...result(a),providerIdentityId:"impostor"})}}),/authenticated Epiphany/);await assert.rejects(()=>admitOperatorRequest({...base,transport:{execute:async a=>({...result(a),result:{...result(a).result,packetSha256:"sha256-wrong"}})}}),/not bound/);}); -test("immutable ledger rejects request and result collisions",()=>{const ledger=new ImmutableOperatorLedger(),r=request(),a={x:1};ledger.admit(r.requestId,r,a);assert.throws(()=>ledger.admit(r.requestId,{...r,nonce:"different"},a),/collision/);ledger.complete(r.requestId,{x:1},"epiphany");assert.throws(()=>ledger.complete(r.requestId,{x:2},"epiphany"),/collision/);}); +test("does not synthesize success without an authenticated transport result",async()=>{const id=await identity(),base={request:request(),ownerDiscordId:"owner",actorLink,identity:id,encode,now:new Date("2026-07-19T10:00:01Z"),ledger:new TestLedger()};await assert.rejects(()=>admitOperatorRequest(base),/authenticated Epiphany/);await assert.rejects(()=>admitOperatorRequest({...base,transport:transport(async a=>({...result(a),providerIdentityId:"impostor"}))}),/authenticated Epiphany/);await assert.rejects(()=>admitOperatorRequest({...base,transport:transport(async a=>({...result(a),result:{...result(a).result,packetSha256:"sha256-wrong"}}))}),/not bound/);}); +test("retransmits an exact durable admission after expiry without mutable actor or signer state",async()=>{const id=await identity(),ledger=new TestLedger(),r=request(),base={request:r,ownerDiscordId:"owner",actorLink,identity:id,encode,ledger};await assert.rejects(()=>admitOperatorRequest({...base,now:new Date("2026-07-19T10:00:01Z"),transport:transport(async()=>{throw new Error("lost result")})}),/lost result/);let calls=0;const recovered=await admitOperatorRequest({...base,ownerDiscordId:"rotated-owner",actorLink:null,identity:null,now:new Date("2026-07-19T10:02:00Z"),transport:transport(async a=>{calls++;return result(a)})});assert.equal(calls,1);assert.equal(recovered.recovered,true);const fresh={...r,requestId:"interaction-2",commandId:"interaction-2",nonce:"interaction-2",sourceEventId:"interaction-2",discordMessageId:"interaction-2"};await assert.rejects(()=>admitOperatorRequest({...base,request:fresh,ledger:new TestLedger(),now:new Date("2026-07-19T10:02:00Z"),transport:transport(async a=>result(a))}),error=>error.code==="expired-new-request");}); +test("does not hide command-id collisions behind an existing completion",async()=>{const id=await identity(),ledger=new TestLedger(),base={ownerDiscordId:"owner",actorLink,identity:id,encode,ledger,now:new Date("2026-07-19T10:00:01Z"),transport:transport(async a=>result(a))};await admitOperatorRequest({...base,request:request()});const collision={...request(),requestId:"interaction-alien",nonce:"alien",sourceEventId:"alien",discordMessageId:"alien"};await assert.rejects(()=>admitOperatorRequest({...base,request:collision}),error=>error.code==="command-id-collision");}); diff --git a/tests/epiphany-operator-cultnet-rudp.test.mjs b/tests/epiphany-operator-cultnet-rudp.test.mjs new file mode 100644 index 0000000..705253d --- /dev/null +++ b/tests/epiphany-operator-cultnet-rudp.test.mjs @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { EventEmitter } from "node:events"; +import { createSocket } from "node:dgram"; +import { createRequire } from "node:module"; +import { resolve } from "node:path"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { enrollOperatorIdentity } from "../tools/epiphany-operator-commands.mjs"; +import { resultReceiptTuple } from "../tools/epiphany-operator-command-documents.mjs"; +import { EPIPHANY_RESULT_SIGNING_PURPOSE,EpiphanyOperatorCultNetRudpTransport,verifySealedReceipt } from "../tools/epiphany-operator-cultnet-rudp.mjs"; +import { signPurpose,trustAnchor } from "../tools/bifrost-signed-identity.mjs"; + +const requireCult=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")); +const msgpack=requireCult("@msgpack/msgpack"),cultnet=requireCult("cultnet-ts"); +function admission(){return {schemaName:"bifrost.operator_command.delivery",schemaVersion:"bifrost.operator_command.delivery.v0",admissionId:"admission-1",packet:{commandId:"command-1",nonce:"nonce-1",sourceEventId:"event-1",sourceActorId:"actor",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:"message-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T12:00:00Z",expiresAt:"2026-07-19T12:01:00Z",command:{kind:"status"}},packetSha256:"sha256-packet",sourceObserverId:"voidbot",sourceObserverRuntimeId:"voidbot-yggdrasil",provider:"bifrost",bifrostAdmissionReceiptId:"receipt-1",authority:"exact_operator_command_only",providerIdentityId:"bifrost",providerSignature:new Uint8Array(64)};} +async function fixture(){const root=await mkdtemp(resolve(tmpdir(),"epiphany-result-")),identity=await enrollOperatorIdentity(resolve(root,"executor.seed")),anchor=trustAnchor(identity,"2026-07-19T11:00:00Z"),a=admission();const result={schemaVersion:"epiphany.operator_command.result.v0",resultId:"result-1",commandId:a.packet.commandId,packetSha256:a.packetSha256,targetRuntimeId:a.packet.targetRuntimeId,disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt:"2026-07-19T12:00:01Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded"};const receipt={schemaVersion:"epiphany.operator_command.sealed_result.v0",result,resultPayloadSha256:`sha256-${createHash("sha256").update(msgpack.encode(result)).digest("hex")}`,commandId:result.commandId,packetSha256:result.packetSha256,targetRuntimeId:result.targetRuntimeId,completedAt:result.completedAt,providerIdentityId:identity.identityId,privateStateExposed:false,executorSignature:new Uint8Array()};receipt.executorSignature=new Uint8Array(signPurpose(identity,EPIPHANY_RESULT_SIGNING_PURPOSE,msgpack.encode(resultReceiptTuple(receipt))));return {a,identity,anchor,receipt};} + +test("verifies the exact host-sealed Epiphany receipt",async()=>{const {a,anchor,receipt}=await fixture();assert.equal(verifySealedReceipt({receipt,admission:a,trustedIdentity:anchor,encode:msgpack.encode}),receipt);}); +test("refuses wrong key, signature, digest, binding, predating completion, and schema",async()=>{const {a,anchor,receipt}=await fixture(),other=await fixture();const cases=[{...receipt,providerIdentityId:other.identity.identityId},{...receipt,executorSignature:new Uint8Array(64)},{...receipt,resultPayloadSha256:"sha256-wrong"},{...receipt,packetSha256:"sha256-wrong"},{...receipt,completedAt:"2026-07-19T11:59:59Z",result:{...receipt.result,completedAt:"2026-07-19T11:59:59Z"}},{...receipt,schemaVersion:"epiphany.operator_command.sealed_result.v9"}];for(const value of cases)assert.throws(()=>verifySealedReceipt({receipt:value,admission:a,trustedIdentity:anchor,encode:msgpack.encode}));}); +test("accepts a correctly signed post-expiry receipt for durable exact recovery",async()=>{const {a,anchor,identity,receipt}=await fixture(),completedAt="2026-07-19T12:02:00Z",result={...receipt.result,completedAt},recovered={...receipt,result,completedAt,resultPayloadSha256:`sha256-${createHash("sha256").update(msgpack.encode(result)).digest("hex")}`,executorSignature:new Uint8Array()};recovered.executorSignature=new Uint8Array(signPurpose(identity,EPIPHANY_RESULT_SIGNING_PURPOSE,msgpack.encode(resultReceiptTuple(recovered))));assert.equal(verifySealedReceipt({receipt:recovered,admission:a,trustedIdentity:anchor,encode:msgpack.encode}),recovered);}); +test("RUDP adapter requires a correlated typed application result and times out otherwise",async()=>{const {a,anchor,receipt}=await fixture();class FakeConnection extends EventEmitter{connected=false;connect(){this.connected=true;}send(){if(this.reply)setImmediate(()=>this.emit("frame",this.reply));}close(){this.closed=true;}}const fakeSocket=()=>{const s=new EventEmitter();s.bind=()=>setImmediate(()=>s.emit("listening"));return s;};const make=(reply,timeoutMs=100)=>{const connection=new FakeConnection();connection.reply=reply;return {connection,transport:new EpiphanyOperatorCultNetRudpTransport({endpoint:"rudp://127.0.0.1:49100",trustedEpiphanyIdentity:anchor,timeoutMs,cultnetRuntime:cultnet,msgpackRuntime:msgpack,socketFactory:fakeSocket,connectionFactory:()=>connection})};};const message={schemaVersion:"cultnet.document_put_raw.v0",messageId:"operator-result-command-1",document:{schemaId:"epiphany.operator_command.sealed_result",recordKey:a.packet.commandId,storedAt:receipt.completedAt,payloadEncoding:"messagepack",payload:msgpack.encode(receipt),sourceRuntimeId:a.packet.targetRuntimeId,sourceAgentId:receipt.providerIdentityId,sourceRole:"epiphany-operator-command-executor",tags:["cultnet.transport.rudp.v0"]}};const ok=make({channelId:"schema",payload:msgpack.encode(message)});assert.equal((await ok.transport.execute(a)).result.resultId,"result-1");const absent=make(undefined);await assert.rejects(()=>absent.transport.execute(a),/Timed out waiting for the correlated/);const wrong=make({channelId:"schema",payload:msgpack.encode({...message,document:{...message.document,recordKey:"alien"}})});await assert.rejects(()=>wrong.transport.execute(a),/substituted result authority/);}); +test("real CultNet RUDP socket path waits for the sealed application document",async()=>{const {a,anchor,receipt}=await fixture(),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:a.packet.targetRuntimeId,socket,mode:"server",connectionId:0xe91f0001,resendDelayMs:25,resendPollMs:5});server.on("frame",frame=>{const request=msgpack.decode(frame.payload);assert.equal(request.document.schemaId,"bifrost.operator_command.delivery");const response={schemaVersion:"cultnet.document_put_raw.v0",messageId:"operator-result-command-1",document:{schemaId:"epiphany.operator_command.sealed_result",recordKey:a.packet.commandId,storedAt:receipt.completedAt,payloadEncoding:"messagepack",payload:msgpack.encode(receipt),sourceRuntimeId:a.packet.targetRuntimeId,sourceAgentId:receipt.providerIdentityId,sourceRole:"epiphany-operator-command-executor",tags:["cultnet.transport.rudp.v0"]}};server.send("schema",msgpack.encode(cultnet.encodeCultNetMessageForWire(response,"cultnet.schema.v0")));});try{const port=socket.address().port,transport=new EpiphanyOperatorCultNetRudpTransport({endpoint:`rudp://127.0.0.1:${port}`,trustedEpiphanyIdentity:anchor,timeoutMs:1000,cultnetRuntime:cultnet,msgpackRuntime:msgpack});const output=await transport.execute(a);assert.equal(output.receipt.providerIdentityId,anchor.identityId);assert.equal(output.result.commandId,a.packet.commandId);}finally{server.close();}}); diff --git a/tests/epiphany-operator-discord-outbox.test.mjs b/tests/epiphany-operator-discord-outbox.test.mjs new file mode 100644 index 0000000..2c43d8d --- /dev/null +++ b/tests/epiphany-operator-discord-outbox.test.mjs @@ -0,0 +1,12 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +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"),{encode}=requireNet("@msgpack/msgpack"); +function request(id="request-1"){return {schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:id,commandId:id,nonce:id,sourceEventId:id,sourceActorDiscordId:"owner",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:id,targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T12:00:00Z",expiresAt:"2026-07-19T12:01:00Z",producerId:"voidbot",producerRuntimeId:"voidbot-yggdrasil",authorityClass:"operator_request_only",status:"pending",command:{kind:"status"}};} +function receipt(id="request-1"){return {schemaVersion:"epiphany.operator_command.sealed_result.v0",result:{disposition:"observed",detail:"bounded",operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged"},resultPayloadSha256:`sha256-${"1".repeat(64)}`,providerIdentityId:"executor",executorSignature:new Uint8Array(64),completedAt:"2026-07-19T12:00:01Z",commandId:id};} +test("Discord outbox publishes immutable request-bound completed and refused deliveries",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-outbox-")),path=resolve(root,"deliveries.cc"),outbox=await EpiphanyOperatorDiscordOutbox.open(path,{cultcacheRuntime:cache,encode}),r=request(),completed=await outbox.publishCompleted(r,receipt());assert.equal(completed.deliveryId,r.requestId);assert.match(completed.requestPayloadSha256,/^sha256-[0-9a-f]{64}$/);assert.match(completed.executorSignatureSha256,/^sha256-[0-9a-f]{64}$/);assert.equal(completed.operatorStatus,"sleeping");assert.equal(completed.brakeStatus,"engaged");assert.equal(outbox.isExactDelivery(r),true);assert.equal(outbox.isExactDelivery({...r,discordChannelId:"alien"}),false);assert.equal(completed.privateStateExposed,false);await outbox.publishCompleted(r,receipt());await assert.rejects(()=>outbox.publishCompleted(r,{...receipt(),result:{...receipt().result,disposition:"applied",detail:"changed"}}),/collision/);const refusedRequest=request("request-2"),refused=await outbox.publishRefused(refusedRequest,{failureCode:"foreign-owner",recordedAt:"2026-07-19T12:00:02Z"});assert.equal(refused.status,"refused");assert.equal(refused.failureCode,"foreign-owner");assert.equal(refused.sealedResultPayloadSha256,"");const reopened=await EpiphanyOperatorDiscordOutbox.open(path,{cultcacheRuntime:cache,encode});assert.equal(reopened.get(r.requestId).status,"completed");assert.equal(reopened.get(refusedRequest.requestId).status,"refused");}); diff --git a/tests/epiphany-operator-ledger.test.mjs b/tests/epiphany-operator-ledger.test.mjs new file mode 100644 index 0000000..252ad0f --- /dev/null +++ b/tests/epiphany-operator-ledger.test.mjs @@ -0,0 +1,12 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { DurableOperatorLedger } from "../tools/epiphany-operator-ledger.mjs"; + +const requireCult=createRequire(resolve("E:/Projects/CultLib/packages/cultcache-ts/package.json")),runtime=requireCult("cultcache-ts"); +function admission(){return {schemaName:"bifrost.operator_command.delivery",schemaVersion:"bifrost.operator_command.delivery.v0",admissionId:"admission-1",packet:{commandId:"command-1",nonce:"nonce-1",sourceEventId:"event-1",sourceActorId:"actor",discordGuildId:"guild",discordChannelId:"ops",discordMessageId:"message-1",targetRuntimeId:"epiphany-yggdrasil",issuedAt:"2026-07-19T12:00:00Z",expiresAt:"2026-07-19T12:01:00Z",command:{kind:"status"}},packetSha256:"sha256-packet",sourceObserverId:"voidbot",sourceObserverRuntimeId:"voidbot-yggdrasil",provider:"bifrost",bifrostAdmissionReceiptId:"receipt-1",authority:"exact_operator_command_only",providerIdentityId:"bifrost",providerSignature:new Uint8Array(64)};} +function receipt(){const result={schemaVersion:"epiphany.operator_command.result.v0",resultId:"result-1",commandId:"command-1",packetSha256:"sha256-packet",targetRuntimeId:"epiphany-yggdrasil",disposition:"observed",consequenceKind:"operator-snapshot",consequenceRef:"snapshot-1",completedAt:"2026-07-19T12:00:01Z",privateStateExposed:false,operatorStatus:"sleeping",stateStatus:"ready",coordinatorAction:"",brakeStatus:"engaged",detail:"bounded"};return {schemaVersion:"epiphany.operator_command.sealed_result.v0",result,resultPayloadSha256:"sha256-result",commandId:result.commandId,packetSha256:result.packetSha256,targetRuntimeId:result.targetRuntimeId,completedAt:result.completedAt,providerIdentityId:"epiphany",privateStateExposed:false,executorSignature:new Uint8Array(64)};} +test("durable operator ledger survives restart and refuses admission/result collisions",async()=>{const root=await mkdtemp(resolve(tmpdir(),"operator-ledger-")),path=resolve(root,"ledger.cc"),first=await DurableOperatorLedger.open(path,{cultcacheRuntime:runtime}),a=admission(),request={schemaName:"voidbot.discord.epiphany_operator_request",schemaVersion:"voidbot.discord.epiphany_operator_request.v0",requestId:a.admissionId,commandId:a.packet.commandId,nonce:a.packet.nonce,sourceEventId:a.packet.sourceEventId,sourceActorDiscordId:"owner",discordGuildId:a.packet.discordGuildId,discordChannelId:a.packet.discordChannelId,discordMessageId:a.packet.discordMessageId,targetRuntimeId:a.packet.targetRuntimeId,issuedAt:a.packet.issuedAt,expiresAt:a.packet.expiresAt,producerId:"voidbot",producerRuntimeId:a.sourceObserverRuntimeId,authorityClass:"operator_request_only",status:"pending",command:a.packet.command},r=receipt(),terminal={schemaVersion:"bifrost.operator_command.terminal.v0",requestId:"rejected-1",commandId:"rejected-1",status:"refused",failureCode:"foreign-owner",recordedAt:"2026-07-19T12:00:00Z",retryable:false,privateStateExposed:false};await first.admit(a.admissionId,request,a);await first.complete(a.admissionId,r.result,r.providerIdentityId,r);await first.terminalize(terminal);const reopened=await DurableOperatorLedger.open(path,{cultcacheRuntime:runtime});assert.equal(reopened.getRequest(a.admissionId).sourceActorDiscordId,"owner");assert.equal(reopened.getAdmission(a.admissionId).packetSha256,a.packetSha256);assert.equal(reopened.getCompletion(r.commandId).result.resultId,r.result.resultId);assert.equal(reopened.getTerminal(terminal.requestId).failureCode,"foreign-owner");await reopened.admit(a.admissionId,request,a);await reopened.complete(a.admissionId,r.result,r.providerIdentityId,r);await reopened.terminalize(terminal);await assert.rejects(()=>reopened.admit(a.admissionId,{...request,sourceActorDiscordId:"alien"},a),/collision/);await assert.rejects(()=>reopened.admit(a.admissionId,request,{...a,packetSha256:"sha256-alien"}),/collision/);await assert.rejects(()=>reopened.complete(a.admissionId,{...r.result,detail:"alien"},r.providerIdentityId,{...r,result:{...r.result,detail:"alien"}}),/collision/);await assert.rejects(()=>reopened.complete(a.admissionId,{...r.result,packetSha256:"sha256-alien"},r.providerIdentityId,{...r,packetSha256:"sha256-alien",result:{...r.result,packetSha256:"sha256-alien"}}),/exact durable admission/);await assert.rejects(()=>reopened.terminalize({...terminal,failureCode:"alien"}),/collision/);}); diff --git a/tests/epiphany-operator-rust-interop.test.mjs b/tests/epiphany-operator-rust-interop.test.mjs new file mode 100644 index 0000000..b5eb5a1 --- /dev/null +++ b/tests/epiphany-operator-rust-interop.test.mjs @@ -0,0 +1,14 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { admissionTuple,parseOperatorAdmission } from "../tools/epiphany-operator-command-documents.mjs"; +import { OPERATOR_SIGNING_PURPOSE } from "../tools/epiphany-operator-commands.mjs"; +import { loadEpiphanyOperatorTrustAnchor,verifySealedReceipt } from "../tools/epiphany-operator-cultnet-rudp.mjs"; +import { verifyPurpose } from "../tools/bifrost-signed-identity.mjs"; + +const fixture=process.env.EPIPHANY_OPERATOR_RUST_FIXTURE??resolve("..","EpiphanyAgent",".epiphany-smoke","operator-command-interop-rust"),requireCult=createRequire(resolve("E:/Projects/CultLib/packages/cultnet-ts/package.json")),{decode,encode}=requireCult("@msgpack/msgpack"); +test("consumes and verifies the Rust-emitted operator admission and sealed receipt",{skip:!existsSync(resolve(fixture,"manifest.json"))&&"Rust interop fixture is not present in this standalone checkout"},async()=>{const manifest=JSON.parse(await readFile(resolve(fixture,"manifest.json"),"utf8")),admissionBytes=await readFile(resolve(fixture,manifest.admissionFile)),receiptBytes=await readFile(resolve(fixture,manifest.sealedResultFile));assert.equal(`sha256-${createHash("sha256").update(admissionBytes).digest("hex")}`,manifest.admissionSha256);assert.equal(`sha256-${createHash("sha256").update(receiptBytes).digest("hex")}`,manifest.sealedResultSha256);const admission=parseOperatorAdmission(decode(admissionBytes)),receipt=decode(receiptBytes),bifrostAnchor=await loadEpiphanyOperatorTrustAnchor(resolve(fixture,manifest.bifrostRawTrustAnchorFile),{decode}),executorAnchor=await loadEpiphanyOperatorTrustAnchor(resolve(fixture,manifest.executorRawTrustAnchorFile),{decode});assert.equal(verifyPurpose(bifrostAnchor.publicKey,OPERATOR_SIGNING_PURPOSE,encode(admissionTuple(admission)),admission.providerSignature),true);assert.equal(verifySealedReceipt({receipt,admission,trustedIdentity:executorAnchor,encode}).commandId,admission.packet.commandId);assert.throws(()=>verifySealedReceipt({receipt:{...receipt,packetSha256:"sha256-hostile"},admission,trustedIdentity:executorAnchor,encode}),/not bound/);assert.equal(manifest.rudpConnectionId,0xe91f0001);assert.equal(manifest.privateStateExposed,false);}); diff --git a/tests/epiphany-operator-worker.test.mjs b/tests/epiphany-operator-worker.test.mjs new file mode 100644 index 0000000..c5cd1de --- /dev/null +++ b/tests/epiphany-operator-worker.test.mjs @@ -0,0 +1,20 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +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 { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { buildOperatorDefinitions,resultReceiptTuple } from "../tools/epiphany-operator-command-documents.mjs"; +import { buildFeedbackDefinitions } from "../tools/persona-feedback-documents.mjs"; +import { enrollOperatorIdentity } from "../tools/epiphany-operator-commands.mjs"; +import { EPIPHANY_RESULT_SIGNING_PURPOSE } from "../tools/epiphany-operator-cultnet-rudp.mjs"; +import { signPurpose,trustAnchor } from "../tools/bifrost-signed-identity.mjs"; +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("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.v0",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"},receipt={schemaVersion:"epiphany.operator_command.sealed_result.v0",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-command-documents.mjs b/tools/epiphany-operator-command-documents.mjs index 1bbebe7..ec5bb8b 100644 --- a/tools/epiphany-operator-command-documents.mjs +++ b/tools/epiphany-operator-command-documents.mjs @@ -4,17 +4,35 @@ export const operatorAdmissionType="bifrost.operator_command.delivery"; export const operatorAdmissionSchema="bifrost.operator_command.delivery.v0"; export const operatorResultType="epiphany.operator_command.result"; export const operatorResultSchema="epiphany.operator_command.result.v0"; +export const operatorResultReceiptType="epiphany.operator_command.sealed_result"; +export const operatorResultReceiptSchema="epiphany.operator_command.sealed_result.v0"; +export const operatorTerminalType="bifrost.operator_command.terminal"; +export const operatorTerminalSchema="bifrost.operator_command.terminal.v0"; +export const operatorDiscordDeliveryType="bifrost.discord.epiphany_operator_delivery"; +export const operatorDiscordDeliverySchema="bifrost.discord.epiphany_operator_delivery.v0"; -export function buildOperatorDefinitions(defineDocumentType){const make=(type,schemaId,name,parse)=>defineDocumentType({type,schemaName:type,schemaId,schemaVersion:schemaId,contentHash:schemaId,global:false,name,schema:{parse}});return {request:make(operatorRequestType,operatorRequestSchema,"requestId",parseOperatorRequest),admission:make(operatorAdmissionType,operatorAdmissionSchema,"admissionId",parseOperatorAdmission),result:make(operatorResultType,operatorResultSchema,"resultId",parseOperatorResult)};} +export function buildOperatorDefinitions(defineDocumentType){const make=(type,schemaId,name,parse)=>defineDocumentType({type,schemaName:type,schemaId,schemaVersion:schemaId,contentHash:schemaId,global:false,name,schema:{parse}});return {request:make(operatorRequestType,operatorRequestSchema,"requestId",parseOperatorRequest),admission:make(operatorAdmissionType,operatorAdmissionSchema,"admissionId",parseOperatorAdmission),result:make(operatorResultType,operatorResultSchema,"resultId",parseOperatorResult),sealedResult:make(operatorResultReceiptType,operatorResultReceiptSchema,"commandId",parseOperatorResultReceipt),terminal:make(operatorTerminalType,operatorTerminalSchema,"requestId",parseOperatorTerminal),discordDelivery:make(operatorDiscordDeliveryType,operatorDiscordDeliverySchema,"deliveryId",parseOperatorDiscordDelivery)};} export function parseOperatorRequest(value){const v=exactRecord(value,["schemaName","schemaVersion","requestId","commandId","nonce","sourceEventId","sourceActorDiscordId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","issuedAt","expiresAt","producerId","producerRuntimeId","authorityClass","status","command"],"operator request"); exact(v.schemaName,operatorRequestType,"schemaName");exact(v.schemaVersion,operatorRequestSchema,"schemaVersion"); for(const k of ["requestId","commandId","nonce","sourceEventId","sourceActorDiscordId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","producerRuntimeId"])text(v[k],k); exact(v.producerId,"voidbot","producerId");exact(v.authorityClass,"operator_request_only","authorityClass");exact(v.status,"pending","status"); const issued=date(v.issuedAt,"issuedAt"),expires=date(v.expiresAt,"expiresAt");if(expires<=issued||expires-issued>60000)throw new Error("operator request TTL must be positive and at most 60 seconds.");parseCommand(v.command);return v;} -export function parseOperatorAdmission(v){v=exactRecord(v,["schemaName","schemaVersion","admissionId","packet","packetSha256","sourceObserverId","sourceObserverRuntimeId","provider","bifrostAdmissionReceiptId","authority","providerIdentityId","providerSignature"],"operator admission");exact(v.schemaName,operatorAdmissionType,"schemaName");exact(v.schemaVersion,operatorAdmissionSchema,"schemaVersion");for(const k of ["admissionId","packetSha256","sourceObserverRuntimeId","bifrostAdmissionReceiptId","providerIdentityId"])text(v[k],k);exact(v.sourceObserverId,"voidbot","sourceObserverId");exact(v.provider,"bifrost","provider");exact(v.authority,"exact_operator_command_only","authority");if(!(v.providerSignature instanceof Uint8Array)||v.providerSignature.length!==64)throw new Error("providerSignature must be 64 bytes.");parsePacket(v.packet);return v;} +export function parseOperatorAdmission(v){v=exactRecord(v,["schemaName","schemaVersion","admissionId","packet","packetSha256","sourceObserverId","sourceObserverRuntimeId","provider","bifrostAdmissionReceiptId","authority","providerIdentityId","providerSignature"],"operator admission");exact(v.schemaName,operatorAdmissionType,"schemaName");exact(v.schemaVersion,operatorAdmissionSchema,"schemaVersion");for(const k of ["admissionId","packetSha256","sourceObserverRuntimeId","bifrostAdmissionReceiptId","providerIdentityId"])text(v[k],k);exact(v.sourceObserverId,"voidbot","sourceObserverId");exact(v.provider,"bifrost","provider");exact(v.authority,"exact_operator_command_only","authority");v.providerSignature=bytes(v.providerSignature,"providerSignature",64);parsePacket(v.packet);return v;} export function parseOperatorResult(v){v=exactRecord(v,["schemaVersion","resultId","commandId","packetSha256","targetRuntimeId","disposition","consequenceKind","consequenceRef","completedAt","privateStateExposed","operatorStatus","stateStatus","coordinatorAction","brakeStatus","detail"],"operator result");exact(v.schemaVersion,operatorResultSchema,"schemaVersion");for(const k of ["resultId","commandId","packetSha256","targetRuntimeId","disposition","consequenceKind","completedAt"])text(v[k],k);if(!["observed","applied","refused"].includes(v.disposition))throw new Error("result disposition is invalid.");if(v.privateStateExposed!==false)throw new Error("operator result must not expose private state.");for(const k of ["consequenceRef","operatorStatus","stateStatus","coordinatorAction","brakeStatus","detail"])if(typeof v[k]!=="string")throw new Error(`${k} must be a string.`);date(v.completedAt,"completedAt");return v;} +export function parseOperatorResultReceipt(v){v=exactRecord(v,["schemaVersion","result","resultPayloadSha256","commandId","packetSha256","targetRuntimeId","completedAt","providerIdentityId","privateStateExposed","executorSignature"],"operator sealed result receipt");exact(v.schemaVersion,operatorResultReceiptSchema,"schemaVersion");parseOperatorResult(v.result);for(const k of ["resultPayloadSha256","commandId","packetSha256","targetRuntimeId","completedAt","providerIdentityId"])text(v[k],k);date(v.completedAt,"completedAt");if(v.privateStateExposed!==false)throw new Error("operator sealed result receipt must not expose private state.");v.executorSignature=bytes(v.executorSignature,"executorSignature",64);return v;} +export function parseOperatorTerminal(v){v=exactRecord(v,["schemaVersion","requestId","commandId","status","failureCode","recordedAt","retryable","privateStateExposed"],"operator terminal disposition");exact(v.schemaVersion,operatorTerminalSchema,"schemaVersion");for(const k of ["requestId","commandId","failureCode","recordedAt"])text(v[k],k);exact(v.status,"refused","status");date(v.recordedAt,"recordedAt");if(v.retryable!==false||v.privateStateExposed!==false)throw new Error("operator terminal disposition must be non-retryable and private-state-free.");return v;} +export function parseOperatorDiscordDelivery(v){v=exactRecord(v,["schemaVersion","deliveryId","requestId","requestPayloadSha256","commandId","discordGuildId","discordChannelId","discordInteractionId","targetRuntimeId","status","disposition","failureCode","detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus","sealedResultPayloadSha256","executorSignatureSha256","resultProviderIdentityId","recordedAt","privateStateExposed"],"operator Discord delivery");exact(v.schemaVersion,operatorDiscordDeliverySchema,"schemaVersion");for(const k of ["deliveryId","requestId","requestPayloadSha256","commandId","discordGuildId","discordChannelId","discordInteractionId","targetRuntimeId","status","recordedAt"])text(v[k],k);if(!/^sha256-[0-9a-f]{64}$/.test(v.requestPayloadSha256))throw new Error("operator Discord delivery request digest is invalid.");if(!["completed","refused"].includes(v.status))throw new Error("operator Discord delivery status is invalid.");for(const k of ["disposition","failureCode","detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus","sealedResultPayloadSha256","executorSignatureSha256","resultProviderIdentityId"])if(typeof v[k]!=="string")throw new Error(`${k} must be a string.`);for(const k of ["detail","operatorStatus","stateStatus","coordinatorAction","brakeStatus"])if(v[k].length>512)throw new Error(`operator Discord delivery ${k} exceeds 512 characters.`);if(v.status==="completed"&&(!v.disposition||!/^sha256-[0-9a-f]{64}$/.test(v.sealedResultPayloadSha256)||!/^sha256-[0-9a-f]{64}$/.test(v.executorSignatureSha256)||!v.resultProviderIdentityId))throw new Error("completed operator Discord delivery lacks sealed-result binding.");if(v.status==="refused"&&!v.failureCode)throw new Error("refused operator Discord delivery lacks failure code.");date(v.recordedAt,"recordedAt");if(v.privateStateExposed!==false)throw new Error("operator Discord delivery must not expose private state.");return v;} +export function operatorRequestTuple(v){return [v.schemaName,v.schemaVersion,v.requestId,v.commandId,v.nonce,v.sourceEventId,v.sourceActorDiscordId,v.discordGuildId,v.discordChannelId,v.discordMessageId,v.targetRuntimeId,v.issuedAt,v.expiresAt,v.producerId,v.producerRuntimeId,v.authorityClass,v.status,commandTuple(v.command)];} export function packetTuple(p){return [p.commandId,p.nonce,p.sourceEventId,p.sourceActorId,p.discordGuildId,p.discordChannelId,p.discordMessageId,p.targetRuntimeId,p.issuedAt,p.expiresAt,commandTuple(p.command)];} export function admissionTuple(a){return [a.schemaVersion,a.admissionId,packetTuple(a.packet),a.packetSha256,a.sourceObserverId,a.sourceObserverRuntimeId,a.provider,a.bifrostAdmissionReceiptId,a.authority,a.providerIdentityId];} +// rmp-serde's compact serializer encodes the nested Rust wire-result struct as +// this positional array inside the signed tuple. The separately hashed result +// payload is encoded with named fields. +export function wireResultTuple(v){return [v.schemaVersion,v.resultId,v.commandId,v.packetSha256,v.targetRuntimeId,v.disposition,v.consequenceKind,v.consequenceRef,v.completedAt,v.privateStateExposed,v.operatorStatus,v.stateStatus,v.coordinatorAction,v.brakeStatus,v.detail];} +export function resultReceiptTuple(v){return [v.schemaVersion,wireResultTuple(v.result),v.resultPayloadSha256,v.commandId,v.packetSha256,v.targetRuntimeId,v.completedAt,v.providerIdentityId,v.privateStateExposed];} function parsePacket(v){v=exactRecord(v,["commandId","nonce","sourceEventId","sourceActorId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId","issuedAt","expiresAt","command"],"operator packet");for(const k of ["commandId","nonce","sourceEventId","sourceActorId","discordGuildId","discordChannelId","discordMessageId","targetRuntimeId"])text(v[k],k);date(v.issuedAt,"issuedAt");date(v.expiresAt,"expiresAt");parseCommand(v.command);return v;} function parseCommand(v){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error("command must be an object.");if(v.kind==="status"||v.kind==="wake")return exactRecord(v,["kind"],"command");if(v.kind==="sleep"){exactRecord(v,["kind","reason"],"command");text(v.reason,"reason");if(v.reason.length>512)throw new Error("sleep reason exceeds 512 characters.");return v;}if(v.kind==="directive"){exactRecord(v,["kind","objective"],"command");text(v.objective,"objective");if(v.objective.length>4096)throw new Error("directive objective exceeds 4096 characters.");return v;}throw new Error("command kind is invalid.");} -function commandTuple(v){if(v.kind==="sleep")return {kind:"sleep",reason:v.reason};if(v.kind==="directive")return {kind:"directive",objective:v.objective};return {kind:v.kind};} +// rmp-serde's compact enum form is the snake-case unit variant string, or a +// two-element [variant, sole-field] tuple for these one-field struct variants. +function commandTuple(v){if(v.kind==="sleep")return ["sleep",v.reason];if(v.kind==="directive")return ["directive",v.objective];return v.kind;} function exactRecord(v,keys,label){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error(`${label} must be an object.`);const actual=Object.keys(v).sort(),expected=[...keys].sort();if(actual.join("\0")!==expected.join("\0"))throw new Error(`${label} has unknown or missing fields.`);return v;} function text(v,label){if(typeof v!=="string"||!v.trim())throw new Error(`${label} is required.`);return v;} function exact(v,e,label){if(v!==e)throw new Error(`${label} must be ${e}.`);} -function date(v,label){text(v,label);const n=Date.parse(v);if(!Number.isFinite(n)||!/T/.test(v))throw new Error(`${label} must be RFC3339.`);return n;} +function date(v,label){text(v,label);if(!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(v))throw new Error(`${label} must be RFC3339.`);const n=Date.parse(v);if(!Number.isFinite(n))throw new Error(`${label} must be RFC3339.`);return n;} +function bytes(v,label,length){const normalized=v instanceof Uint8Array?v:Array.isArray(v)&&v.every(x=>Number.isInteger(x)&&x>=0&&x<=255)?Uint8Array.from(v):null;if(!normalized||normalized.length!==length)throw new Error(`${label} must be ${length} bytes.`);return normalized;} diff --git a/tools/epiphany-operator-commands.mjs b/tools/epiphany-operator-commands.mjs index 0f1d3f5..db934c9 100644 --- a/tools/epiphany-operator-commands.mjs +++ b/tools/epiphany-operator-commands.mjs @@ -1,34 +1,59 @@ import { createHash } from "node:crypto"; import { resolve } from "node:path"; -import { admissionTuple, operatorAdmissionSchema, parseOperatorAdmission, parseOperatorRequest, parseOperatorResult, packetTuple } from "./epiphany-operator-command-documents.mjs"; -import { enrollSignedIdentity, loadSignedIdentity, signPurpose, trustAnchor } from "./bifrost-signed-identity.mjs"; +import { admissionTuple,operatorAdmissionSchema,parseOperatorAdmission,parseOperatorRequest,parseOperatorResult,parseOperatorResultReceipt,packetTuple } from "./epiphany-operator-command-documents.mjs"; +import { enrollSignedIdentity,loadSignedIdentity,signPurpose,trustAnchor } from "./bifrost-signed-identity.mjs"; export const OPERATOR_SIGNING_PURPOSE="bifrost.operator-command.delivery.v0"; export const defaultOperatorIdentityPath=()=>process.env.BIFROST_EPIPHANY_OPERATOR_PRIVATE_KEY??(process.platform==="win32"?resolve(".bifrost","private","epiphany-operator-ed25519.seed"):"/var/lib/gamecult/bifrost/epiphany-operator/operator-ed25519.seed"); -// The transport is deliberately a narrow port. The live CultNet RUDP request/reply -// adapter is not yet present in Bifrost; callers must inject an authenticated port. -export async function admitOperatorRequest({request,ownerDiscordId,actorLink,identity,encode,transport,expectedEpiphanyIdentityId,now=new Date(),ledger=new ImmutableOperatorLedger()}){ - parseOperatorRequest(request); - if(request.sourceActorDiscordId!==ownerDiscordId)throw new Error("Epiphany operator command requires the exact configured Discord owner."); - if(!actorLink||actorLink.guildId!==request.discordGuildId||actorLink.discordAuthorId!==request.sourceActorDiscordId||!actorLink.bifrostActorId?.trim()||!actorLink.heimdallActorRef?.trim())throw new Error("Epiphany operator command requires the exact existing Bifrost actor link and Heimdall actor reference."); - const at=now instanceof Date?now:new Date(now);if(!Number.isFinite(at.getTime())||Date.parse(request.issuedAt)>at.getTime()||Date.parse(request.expiresAt)at.getTime()||Date.parse(request.expiresAt)x instanceof Uint8Array?Buffer.from(x).toString("hex"):x);} diff --git a/tools/epiphany-operator-cultnet-rudp.mjs b/tools/epiphany-operator-cultnet-rudp.mjs new file mode 100644 index 0000000..095316a --- /dev/null +++ b/tools/epiphany-operator-cultnet-rudp.mjs @@ -0,0 +1,86 @@ +import { createHash } from "node:crypto"; +import { createSocket } from "node:dgram"; +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + operatorAdmissionType, + operatorResultReceiptType, + parseOperatorAdmission, + parseOperatorResultReceipt, + resultReceiptTuple, +} from "./epiphany-operator-command-documents.mjs"; +import { verifyPurpose } from "./bifrost-signed-identity.mjs"; + +export const EPIPHANY_RESULT_SIGNING_PURPOSE="epiphany.operator-command.sealed-result.v0"; +const RUDP_CONNECTION_ID=0xe91f0001; + +export class EpiphanyOperatorCultNetRudpTransport { + constructor({endpoint,runtimeId="bifrost-yggdrasil",trustedEpiphanyIdentity,timeoutMs=5000,cultnetRuntime,msgpackRuntime,socketFactory=createSocket,connectionFactory}={}){ + this.endpoint=parseEndpoint(endpoint); + this.runtimeId=required(runtimeId,"Bifrost transport runtime id"); + this.trustedIdentity=parseTrustAnchor(trustedEpiphanyIdentity); + if(!Number.isInteger(timeoutMs)||timeoutMs<100||timeoutMs>60000)throw new Error("Epiphany operator result timeout must be 100..=60000 milliseconds."); + this.timeoutMs=timeoutMs; + const loaded=(!cultnetRuntime||!msgpackRuntime)?loadCultNetRuntime():{}; + this.cultnet=cultnetRuntime??loaded.cultnet; + this.msgpack=msgpackRuntime??loaded.msgpack; + this.socketFactory=socketFactory; + this.connectionFactory=connectionFactory??(options=>new this.cultnet.CultNetRudpSocketTransportConnection(options)); + this.tail=Promise.resolve(); + } + + get trustedIdentityId(){return this.trustedIdentity.identityId;} + verifyReceipt(admission,receipt){return verifySealedReceipt({receipt,admission,trustedIdentity:this.trustedIdentity,encode:this.msgpack.encode});} + + execute(admission){ + const work=this.tail.then(()=>this.#executeOne(admission)); + this.tail=work.catch(()=>undefined); + return work; + } + + async #executeOne(admission){ + parseOperatorAdmission(admission); + const socket=this.socketFactory("udp4"); + await bindSocket(socket); + const connection=this.connectionFactory({runtimeId:this.runtimeId,socket,mode:"client",remoteHost:this.endpoint.host,remotePort:this.endpoint.port,connectionId:RUDP_CONNECTION_ID,resendDelayMs:50,resendPollMs:10}); + try{ + connection.connect(); + await waitForConnected(connection,this.timeoutMs); + const response=waitForReceipt(connection,this.msgpack,this.timeoutMs,admission); + const message={schemaVersion:"cultnet.document_put_raw.v0",messageId:`operator-request-${admission.admissionId}`,document:{schemaId:operatorAdmissionType,recordKey:admission.admissionId,storedAt:admission.packet.issuedAt,payloadEncoding:"messagepack",payload:this.msgpack.encode(admission),sourceRuntimeId:admission.sourceObserverRuntimeId,sourceAgentId:admission.providerIdentityId,sourceRole:"bifrost-operator-admission",tags:["cultnet.transport.rudp.v0"]}}; + connection.send("schema",this.msgpack.encode(this.cultnet.encodeCultNetMessageForWire(message,"cultnet.schema.v0"))); + const receipt=await response; + this.verifyReceipt(admission,receipt); + return {providerIdentityId:receipt.providerIdentityId,result:receipt.result,receipt}; + }finally{connection.close();} + } +} + +export async function loadEpiphanyOperatorTrustAnchor(path,{decode}={}){const runtime=decode?{decode}:loadCultNetRuntime().msgpack,value=runtime.decode(await readFile(path));if(!Array.isArray(value)||value.length!==6)throw new Error("Epiphany operator trust anchor must be the raw compact 6-field MessagePack crossing artifact.");return parseTrustAnchor({schemaVersion:value[0],identityId:value[1],publicKey:value[2],assurance:value[3],identityCreatedAt:value[4],sourceIdentityRecordSha256:value[5]});} + +export function verifySealedReceipt({receipt,admission,trustedIdentity,encode}){ + parseOperatorAdmission(admission);parseOperatorResultReceipt(receipt);const anchor=parseTrustAnchor(trustedIdentity); + if(receipt.providerIdentityId!==anchor.identityId)throw new Error("Epiphany sealed result used an untrusted host identity."); + if(receipt.commandId!==admission.packet.commandId||receipt.packetSha256!==admission.packetSha256||receipt.targetRuntimeId!==admission.packet.targetRuntimeId)throw new Error("Epiphany sealed result receipt is not bound to the admitted command."); + if(receipt.result.commandId!==receipt.commandId||receipt.result.packetSha256!==receipt.packetSha256||receipt.result.targetRuntimeId!==receipt.targetRuntimeId||receipt.result.completedAt!==receipt.completedAt)throw new Error("Epiphany sealed result receipt substituted its embedded application result."); + // Expiry gates first admission, not exact crash recovery. Epiphany may seal + // an already-admitted command after its lease while recovering the one + // idempotent consequence; Bifrost's durable ledger owns whether such a + // post-expiry retransmission was authorized. + const completed=Date.parse(receipt.completedAt),issued=Date.parse(admission.packet.issuedAt); + if(completed{const finish=(fn,value)=>{clearTimeout(timer);connection.off?.("frame",onFrame);connection.off?.("error",onError);fn(value);};const onError=error=>finish(reject,error);const onFrame=frame=>{try{if(frame.channelId!=="schema")throw new Error("Epiphany operator response arrived on a foreign CultNet channel.");const message=msgpack.decode(frame.payload);if(!message||message.schemaVersion!=="cultnet.document_put_raw.v0")throw new Error("Epiphany operator response is not a CultNet raw typed document.");const d=message.document;if(!d||d.schemaId!==operatorResultReceiptType||d.recordKey!==admission.packet.commandId||d.payloadEncoding!=="messagepack"||d.sourceRole!=="epiphany-operator-command-executor"||d.sourceRuntimeId!==admission.packet.targetRuntimeId)throw new Error("Epiphany operator response envelope substituted result authority.");const receipt=msgpack.decode(d.payload);if(d.sourceAgentId!==receipt?.providerIdentityId)throw new Error("Epiphany operator response envelope substituted executor identity.");finish(resolveReceipt,receipt);}catch(error){finish(reject,error);}};const timer=setTimeout(()=>finish(reject,new Error("Timed out waiting for the correlated Epiphany application result.")),timeoutMs);connection.on("frame",onFrame);connection.on("error",onError);});} +async function waitForConnected(connection,timeoutMs){const started=Date.now();while(!connection.connected){if(Date.now()-started>=timeoutMs)throw new Error("Timed out establishing the Epiphany CultNet RUDP session.");await new Promise(done=>setTimeout(done,5));}} +function bindSocket(socket){return new Promise((resolveBind,reject)=>{const fail=error=>{socket.off("listening",ready);reject(error)};const ready=()=>{socket.off("error",fail);resolveBind()};socket.once("error",fail);socket.once("listening",ready);socket.bind(0,"0.0.0.0");});} +function parseEndpoint(value){required(value,"Epiphany operator RUDP endpoint");const url=new URL(value);if(url.protocol!=="rudp:"||!url.hostname||!url.port||(url.pathname&&url.pathname!=="/"))throw new Error("Epiphany operator endpoint must be rudp://host:port.");const port=Number(url.port);if(!Number.isInteger(port)||port<1||port>65535)throw new Error("Epiphany operator endpoint port is invalid.");return {host:url.hostname,port};} +function parseTrustAnchor(value){if(!value||typeof value!=="object"||Array.isArray(value))throw new Error("Trusted Epiphany host identity is required.");const keys=Object.keys(value).sort(),expected=["assurance","identityCreatedAt","identityId","publicKey","schemaVersion","sourceIdentityRecordSha256"].sort();if(keys.join("\0")!==expected.join("\0")||value.schemaVersion!=="epiphany.host_identity_trust_anchor.v0"||typeof value.identityId!=="string"||!value.assurance?.trim()||!/^sha256-[0-9a-f]{64}$/.test(value.sourceIdentityRecordSha256)||!Number.isFinite(Date.parse(value.identityCreatedAt)))throw new Error("Trusted Epiphany host identity violates its exact schema.");value.publicKey=value.publicKey instanceof Uint8Array?value.publicKey:Array.isArray(value.publicKey)&&value.publicKey.every(x=>Number.isInteger(x)&&x>=0&&x<=255)?Uint8Array.from(value.publicKey):null;if(!value.publicKey||value.publicKey.length!==32)throw new Error("Trusted Epiphany host identity public key must be 32 bytes.");const derived=createHash("sha256").update(Buffer.from("epiphany.host-incarnation.identity.v0\0")).update(value.publicKey).digest("hex");if(derived!==value.identityId)throw new Error("Trusted Epiphany host identity id does not match its public key.");return value;} +function loadCultNetRuntime(){const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(projects,"CultLib")),pkg=resolve(cult,"packages","cultnet-ts","package.json");if(!existsSync(pkg))throw new Error(`CultNet TypeScript runtime is unavailable at ${pkg}.`);const requireCult=createRequire(pkg);return {cultnet:requireCult("cultnet-ts"),msgpack:requireCult("@msgpack/msgpack")};} +function required(value,label){if(typeof value!=="string"||!value.trim())throw new Error(`${label} is required.`);return value;} diff --git a/tools/epiphany-operator-discord-outbox.mjs b/tools/epiphany-operator-discord-outbox.mjs new file mode 100644 index 0000000..62611a8 --- /dev/null +++ b/tools/epiphany-operator-discord-outbox.mjs @@ -0,0 +1,19 @@ +import { createHash } from "node:crypto"; +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { buildOperatorDefinitions,operatorRequestTuple } from "./epiphany-operator-command-documents.mjs"; + +export class EpiphanyOperatorDiscordOutbox { + constructor(cache,definition,encode){this.cache=cache;this.definition=definition;this.encode=encode;} + static async open(path,{cultcacheRuntime,encode}={}){const runtime=cultcacheRuntime??loadCultCacheRuntime(),definition=buildOperatorDefinitions(runtime.defineDocumentType).discordDelivery,cache=runtime.CultCache.builder().withDocumentType(definition).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();if(typeof encode!=="function")throw new Error("operator Discord outbox requires the canonical MessagePack encoder");await cache.pullAllBackingStores();return new EpiphanyOperatorDiscordOutbox(cache,definition,encode);} + get(requestId){return this.cache.get(this.definition,requestId);} + isExactDelivery(request){const value=this.get(request.requestId);return Boolean(value&&value.requestPayloadSha256===this.requestDigest(request)&&value.commandId===request.commandId&&value.discordGuildId===request.discordGuildId&&value.discordChannelId===request.discordChannelId&&value.discordInteractionId===request.sourceEventId&&value.targetRuntimeId===request.targetRuntimeId);} + async publishCompleted(request,receipt){const value=this.base(request,{status:"completed",disposition:receipt.result.disposition,failureCode:"",detail:receipt.result.detail,operatorStatus:receipt.result.operatorStatus,stateStatus:receipt.result.stateStatus,coordinatorAction:receipt.result.coordinatorAction,brakeStatus:receipt.result.brakeStatus,sealedResultPayloadSha256:receipt.resultPayloadSha256,executorSignatureSha256:`sha256-${createHash("sha256").update(receipt.executorSignature).digest("hex")}`,resultProviderIdentityId:receipt.providerIdentityId,recordedAt:receipt.completedAt});return this.put(value);} + async publishRefused(request,terminal){const value=this.base(request,{status:"refused",disposition:"",failureCode:terminal.failureCode,detail:terminal.failureCode,operatorStatus:"",stateStatus:"",coordinatorAction:"",brakeStatus:"",sealedResultPayloadSha256:"",executorSignatureSha256:"",resultProviderIdentityId:"",recordedAt:terminal.recordedAt});return this.put(value);} + requestDigest(request){return `sha256-${createHash("sha256").update(this.encode(operatorRequestTuple(request))).digest("hex")}`;} + base(request,result){return {schemaVersion:"bifrost.discord.epiphany_operator_delivery.v0",deliveryId:request.requestId,requestId:request.requestId,requestPayloadSha256:this.requestDigest(request),commandId:request.commandId,discordGuildId:request.discordGuildId,discordChannelId:request.discordChannelId,discordInteractionId:request.sourceEventId,targetRuntimeId:request.targetRuntimeId,...result,privateStateExposed:false};} + async put(value){const current=this.get(value.requestId);if(current&&stable(current)!==stable(value))throw new Error("operator Discord delivery identity collision");if(!current)await this.cache.put(this.definition,value.deliveryId,value);return current??value;} +} +function loadCultCacheRuntime(){const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(projects,"CultLib")),pkg=resolve(cult,"packages","cultcache-ts","package.json");if(!existsSync(pkg))throw new Error(`CultCache TypeScript runtime is unavailable at ${pkg}.`);const requireCult=createRequire(pkg);return requireCult("cultcache-ts");} +function stable(v){return JSON.stringify(v,(_k,x)=>x instanceof Uint8Array?Buffer.from(x).toString("hex"):x);} diff --git a/tools/epiphany-operator-ledger.mjs b/tools/epiphany-operator-ledger.mjs new file mode 100644 index 0000000..b1410e4 --- /dev/null +++ b/tools/epiphany-operator-ledger.mjs @@ -0,0 +1,20 @@ +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { buildOperatorDefinitions } from "./epiphany-operator-command-documents.mjs"; + +export class DurableOperatorLedger { + constructor(cache,definitions){this.cache=cache;this.definitions=definitions;} + static async open(path,{cultcacheRuntime}={}){const runtime=cultcacheRuntime??loadCultCacheRuntime(),definitions=buildOperatorDefinitions(runtime.defineDocumentType),cache=runtime.CultCache.builder().withDocumentType(definitions.request).withDocumentType(definitions.admission).withDocumentType(definitions.sealedResult).withDocumentType(definitions.terminal).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();await cache.pullAllBackingStores();return new DurableOperatorLedger(cache,definitions);} + async admit(id,request,admission){if(id!==request.requestId||id!==admission.admissionId||request.commandId!==admission.packet.commandId)throw new Error("operator admission ledger key is not bound to request and admission identity");const currentRequest=this.cache.get(this.definitions.request,id),currentAdmission=this.cache.get(this.definitions.admission,id);if(currentRequest&&stable(currentRequest)!==stable(request)||currentAdmission&&stable(currentAdmission)!==stable(admission))throw new Error("operator request identity collision");if(!currentRequest)await this.cache.put(this.definitions.request,id,request);if(!currentAdmission)await this.cache.put(this.definitions.admission,id,admission);} + async complete(id,result,providerIdentityId,receipt){const admission=this.cache.get(this.definitions.admission,id),request=this.cache.get(this.definitions.request,id);if(!admission||!request)throw new Error("operator result has no durable admitted request");if(!receipt||receipt.commandId!==result.commandId||receipt.providerIdentityId!==providerIdentityId||stable(receipt.result)!==stable(result)||receipt.commandId!==admission.packet.commandId||receipt.packetSha256!==admission.packetSha256||receipt.targetRuntimeId!==admission.packet.targetRuntimeId||request.commandId!==receipt.commandId)throw new Error("operator completion is not bound to its exact durable admission");const current=this.cache.get(this.definitions.sealedResult,result.commandId);if(current&&stable(current)!==stable(receipt))throw new Error("operator result identity collision");if(!current)await this.cache.put(this.definitions.sealedResult,result.commandId,receipt);} + async terminalize(value){const current=this.cache.get(this.definitions.terminal,value.requestId);if(current&&stable(current)!==stable(value))throw new Error("operator terminal disposition identity collision");if(!current)await this.cache.put(this.definitions.terminal,value.requestId,value);} + getAdmission(id){return this.cache.get(this.definitions.admission,id);} + getRequest(id){return this.cache.get(this.definitions.request,id);} + getCompletion(commandId){return this.cache.get(this.definitions.sealedResult,commandId);} + getTerminal(requestId){return this.cache.get(this.definitions.terminal,requestId);} + isExactCompletion(requestId,commandId){const request=this.getRequest(requestId),admission=this.getAdmission(requestId),completion=this.getCompletion(commandId);return Boolean(request&&admission&&completion&&request.commandId===commandId&&admission.packet.commandId===commandId&&completion.commandId===commandId&&completion.packetSha256===admission.packetSha256&&completion.targetRuntimeId===admission.packet.targetRuntimeId);} +} + +function loadCultCacheRuntime(){const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(projects,"CultLib")),pkg=resolve(cult,"packages","cultcache-ts","package.json");if(!existsSync(pkg))throw new Error(`CultCache TypeScript runtime is unavailable at ${pkg}.`);const requireCult=createRequire(pkg);return requireCult("cultcache-ts");} +function stable(v){return JSON.stringify(v,(_k,x)=>x instanceof Uint8Array?Buffer.from(x).toString("hex"):x);} diff --git a/tools/epiphany-operator-worker.mjs b/tools/epiphany-operator-worker.mjs new file mode 100644 index 0000000..3749eb9 --- /dev/null +++ b/tools/epiphany-operator-worker.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +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 { 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.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(); + const operatorDefs=buildOperatorDefinitions(runtime.defineDocumentType); + const feedbackDefs=buildFeedbackDefinitions(runtime.defineDocumentType); + const requestStore=requiredOption("request-store","BIFROST_EPIPHANY_OPERATOR_REQUEST_STORE"); + const privateStore=requiredOption("private-store","BIFROST_PERSONA_FEEDBACK_PRIVATE_STORE"); + const ledgerStore=requiredOption("ledger-store","BIFROST_EPIPHANY_OPERATOR_LEDGER_STORE"); + const deliveryStore=requiredOption("delivery-store","BIFROST_EPIPHANY_OPERATOR_DISCORD_DELIVERY_STORE"); + const ownerDiscordId=requiredOption("owner-discord-id","BIFROST_EPIPHANY_OPERATOR_OWNER_DISCORD_ID"); + assertDistinctStores(requestStore,privateStore,ledgerStore,deliveryStore); + const identity=await loadOperatorIdentity(resolve(requiredOption("private-key","BIFROST_EPIPHANY_OPERATOR_PRIVATE_KEY"))); + const anchor=await loadEpiphanyOperatorTrustAnchor(resolve(requiredOption("epiphany-trust-anchor","BIFROST_EPIPHANY_EXECUTOR_TRUST_ANCHOR")),{decode:runtime.decode}); + const transport=new EpiphanyOperatorCultNetRudpTransport({endpoint:requiredOption("epiphany-rudp","BIFROST_EPIPHANY_OPERATOR_RUDP"),runtimeId:option("runtime-id","BIFROST_RUNTIME_ID")||"bifrost-yggdrasil",trustedEpiphanyIdentity:anchor,timeoutMs:integerOption("timeout-ms",5000,100,60000),cultnetRuntime:runtime.cultnet,msgpackRuntime:runtime.msgpack}); + const ledger=await DurableOperatorLedger.open(resolve(ledgerStore),{cultcacheRuntime:runtime.cultcache}); + const outbox=await EpiphanyOperatorDiscordOutbox.open(resolve(deliveryStore),{cultcacheRuntime:runtime.cultcache,encode:runtime.encode}); + const requests=runtime.CultCache.builder().withDocumentType(operatorDefs.request).withGenericStore(new runtime.SingleFileMessagePackBackingStore(resolve(requestStore))).build(); + const privateCache=runtime.CultCache.builder().withDocumentType(feedbackDefs.actorLink).withGenericStore(new runtime.SingleFileMessagePackBackingStore(resolve(privateStore))).build(); + const interval=integerOption("interval-ms",500,100,60000); + do{ + const output=await processPending({requests,privateCache,operatorDefs,feedbackDefs,ledger,outbox,ownerDiscordId,identity,transport,encode:runtime.encode}); + process.stdout.write(`${JSON.stringify({schemaVersion:"bifrost.epiphany_operator_worker_pulse.v0",status:output.every(x=>["completed","refused"].includes(x.status))?"completed":"degraded",processed:output.length,results:output,privateStateExposed:false})}\n`); + if(command==="once")return; + await new Promise(done=>setTimeout(done,interval)); + }while(true); +} + +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)); + const output=[]; + for(const request of pending){ + const terminal=ledger.getTerminal(request.requestId); + if(terminal){await outbox.publishRefused(request,terminal);output.push(summary(request,"refused","",terminal.failureCode));continue;} + try{ + const actorLink=privateCache.get(feedbackDefs.actorLink,`${request.discordGuildId}:${request.sourceActorDiscordId}`); + const result=await admitOperatorRequest({request,ownerDiscordId,actorLink,identity,encode,transport,ledger}); + await outbox.publishCompleted(request,result.receipt); + output.push(summary(request,"completed",result.result.disposition,result.result.detail)); + }catch(error){ + if(error instanceof OperatorCommandPermanentRefusal){ + const value={schemaVersion:"bifrost.operator_command.terminal.v0",requestId:request.requestId,commandId:request.commandId,status:"refused",failureCode:error.code,recordedAt:new Date().toISOString(),retryable:false,privateStateExposed:false}; + await ledger.terminalize(value);await outbox.publishRefused(request,value); + } + output.push(summary(request,error instanceof OperatorCommandPermanentRefusal?"refused":"retryable-failure","",error instanceof OperatorCommandPermanentRefusal?error.code:"transport-or-ledger-unavailable")); + } + } + return output; +} + +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 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;} +function assertDistinctStores(...paths){const resolved=paths.map(path=>resolve(path).toLowerCase());if(new Set(resolved).size!==resolved.length)throw new Error("operator request, actor-link, immutable ledger, and Discord delivery stores must be physically distinct");} diff --git a/tools/provider-advertisement.mjs b/tools/provider-advertisement.mjs index 8873f8d..63b54c4 100644 --- a/tools/provider-advertisement.mjs +++ b/tools/provider-advertisement.mjs @@ -168,6 +168,9 @@ const motionCommandCultMeshUri = `cultmesh://${locatedService}/commands/motion`; const epiphanyOperatorRequestSchemaId = "voidbot.discord.epiphany_operator_request.v0"; const epiphanyOperatorAdmissionSchemaId = "bifrost.operator_command.delivery.v0"; const epiphanyOperatorResultSchemaId = "epiphany.operator_command.result.v0"; +const epiphanyOperatorSealedResultSchemaId = "epiphany.operator_command.sealed_result.v0"; +const epiphanyOperatorTerminalSchemaId = "bifrost.operator_command.terminal.v0"; +const epiphanyOperatorDiscordDeliverySchemaId = "bifrost.discord.epiphany_operator_delivery.v0"; const advertisementDefinition = defineDocumentType({ type: documentType, @@ -334,7 +337,8 @@ function buildAdvertisement(options) { endpoint("motion-surface", motionSurfaceCultMeshUri, "gamecult.eve.surface.v1", ["cultmesh", "governance", "motion"]), endpoint("motion-commands", motionCommandCultMeshUri, "bifrost.motion_command.v0", ["command", "cultmesh", "governance", "motion"]), endpoint("epiphany-operator-requests", `${locatedService}/commands/epiphany-operator`, epiphanyOperatorRequestSchemaId, ["command", "discord", "operator", "cultmesh"]), - endpoint("epiphany-operator-results", `${locatedService}/receipts/epiphany-operator`, epiphanyOperatorResultSchemaId, ["receipt", "epiphany", "operator", "cultmesh"]), + endpoint("epiphany-operator-results", `${locatedService}/receipts/epiphany-operator`, epiphanyOperatorSealedResultSchemaId, ["receipt", "epiphany", "operator", "cultnet", "rudp"]), + endpoint("epiphany-operator-discord-deliveries", `${locatedService}/deliveries/discord/epiphany-operator`, epiphanyOperatorDiscordDeliverySchemaId, ["delivery", "discord", "operator", "cultcache"]), ], routes: [ route("cultmesh-store", ".bifrost/provider-store.cc", "local-cultmesh", true), @@ -377,6 +381,9 @@ function buildAdvertisement(options) { schema(epiphanyOperatorRequestSchemaId, "voidbot.discord.epiphany_operator_request", "strict owner-authored Discord operator request inbox"), schema(epiphanyOperatorAdmissionSchemaId, "bifrost.operator_command.delivery", "Bifrost-signed expiring Epiphany operator admission"), schema(epiphanyOperatorResultSchemaId, "epiphany.operator_command.result", "Epiphany-origin command result returned across the authenticated transport boundary"), + schema(epiphanyOperatorSealedResultSchemaId, "epiphany.operator_command.sealed_result", "Epiphany host-signed receipt binding the exact application result to command, packet, runtime, and payload digest"), + schema(epiphanyOperatorTerminalSchemaId, "bifrost.operator_command.terminal", "Bifrost-owned immutable terminal refusal for a non-retryable operator request"), + schema(epiphanyOperatorDiscordDeliverySchemaId, "bifrost.discord.epiphany_operator_delivery", "Bifrost-owned immutable Discord result delivery bound to the original request and sealed Epiphany result or terminal refusal"), ], witnesses: [ witness(".bifrost/provider-store.cc", `${schemaId}; ${surfaceSchemaId}; ${interfaceBindingSchemaId}; ${discordPostCommandSchemaId}; ${discordPostReceiptSchemaId}`, "current", "typed provider advertisement, operator surface, Eve interface binding, and Bifrost Discord command request/receipt store"), @@ -389,6 +396,8 @@ function buildAdvertisement(options) { witness(".bifrost/bridge-receipts.cc", crossingReceiptSchemaId, "current", "canonical governed public crossing receipt history"), witness(".bifrost/eve-surfaces.cc", "gamecult.eve.surface.v1", "planned-export", "future product Eve/CultUI surfaces not yet in the provider store"), witness(".bifrost/epiphany-operator-requests.cc", epiphanyOperatorRequestSchemaId, "configured", "dedicated VoidBot-writable Bifrost operator request inbox; never an Epiphany store"), + witness(".bifrost/epiphany-operator-ledger.cc", `${epiphanyOperatorAdmissionSchemaId}; ${epiphanyOperatorSealedResultSchemaId}; ${epiphanyOperatorTerminalSchemaId}`, "configured", "Bifrost-owned immutable admission, host-sealed application-result, and terminal-refusal ledger"), + witness(".bifrost/epiphany-operator-discord-deliveries.cc", epiphanyOperatorDiscordDeliverySchemaId, "configured", "Bifrost-writable and VoidBot-readable immutable operator result delivery outbox"), ], surfaces: [ surface("bifrost", "Bifrost Operator Dashboard", "gamecult.bifrost.surface.operator", "gamecult.eve.surface_state.v1", ".bifrost/provider-store.cc", "eve", [ @@ -500,7 +509,7 @@ function buildAdvertisement(options) { ]), boundary("epiphany-operator", "Bifrost admission; Epiphany execution", [ "admit exact owner-only status, sleep, wake, and directive requests", - "require an existing Bifrost actor link and Heimdall actor reference", + "require the exact configured Discord owner and persisted Bifrost actor-link mapping; Heimdall reference is provenance until signed claims are live", "sign Rust-compatible expiring operator admissions with a dedicated Ed25519 identity", "relay only Epiphany-origin results bound to the admitted command", ], [